diff --git a/src/main/git/worktree-include-file.test.ts b/src/main/git/worktree-include-file.test.ts new file mode 100644 index 00000000000..3706510a98b --- /dev/null +++ b/src/main/git/worktree-include-file.test.ts @@ -0,0 +1,139 @@ +import { mkdtempSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { parseWorktreeIncludeFile, resolveWorktreeIncludePaths } from './worktree-include-file' +import { gitExecFileAsync } from './runner' + +vi.mock('./runner', () => ({ + gitExecFileAsync: vi.fn() +})) + +const gitExecFileAsyncMock = vi.mocked(gitExecFileAsync) + +/** check-ignore echoes back every stdin path present in `ignored` (all requested + * when unset); exit code 1 with empty stdout means "none ignored". */ +function mockCheckIgnore(ignored?: string[]): void { + gitExecFileAsyncMock.mockImplementation(async (args, execOptions) => { + if (!args.includes('check-ignore')) { + throw new Error(`Unexpected git args: ${args.join(' ')}`) + } + const requested = (execOptions.stdin ?? '').split('\0').filter(Boolean) + const ignoredSet = new Set(ignored ?? requested) + const matched = requested.filter((path) => ignoredSet.has(path)) + if (matched.length === 0) { + throw Object.assign(new Error('no matches'), { code: 1 }) + } + return { stdout: matched.map((path) => `${path}\0`).join(''), stderr: '' } + }) +} + +describe('parseWorktreeIncludeFile', () => { + it('skips blank lines and comments, dedupes, strips ./ and trailing slash', () => { + const entries = parseWorktreeIncludeFile( + '# secrets\n\n.env\n \n# more\n./config/secrets.json\n.vscode/\n.env\n' + ) + expect(entries).toEqual(['.env', 'config/secrets.json', '.vscode']) + }) + + it('normalizes backslashes to forward slashes', () => { + expect(parseWorktreeIncludeFile('apps\\web\\.env\n')).toEqual(['apps/web/.env']) + }) +}) + +describe('resolveWorktreeIncludePaths', () => { + let repo: string + let warn: ReturnType + + beforeEach(() => { + repo = mkdtempSync(join(tmpdir(), 'orca-worktreeinclude-')) + gitExecFileAsyncMock.mockReset() + warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + }) + + afterEach(() => { + warn.mockRestore() + rmSync(repo, { recursive: true, force: true }) + }) + + function writeInclude(content: string): void { + writeFileSync(join(repo, '.worktreeinclude'), content) + } + + it('returns [] without spawning git when the file is absent', async () => { + await expect(resolveWorktreeIncludePaths(repo)).resolves.toEqual([]) + expect(gitExecFileAsyncMock).not.toHaveBeenCalled() + }) + + it('resolves existing gitignored literal files and directories', async () => { + writeInclude('.env\nconfig/secrets.json\n.vscode/\nmissing.txt\n') + writeFileSync(join(repo, '.env'), 'A=1') + mkdirSync(join(repo, 'config')) + writeFileSync(join(repo, 'config', 'secrets.json'), '{}') + mkdirSync(join(repo, '.vscode')) + mockCheckIgnore(['.env', 'config/secrets.json', '.vscode']) + + await expect(resolveWorktreeIncludePaths(repo)).resolves.toEqual([ + '.env', + '.vscode', + 'config/secrets.json' + ]) + }) + + it('drops listed paths that exist but are not gitignored', async () => { + writeInclude('.env\ntracked.json\n') + writeFileSync(join(repo, '.env'), 'A=1') + writeFileSync(join(repo, 'tracked.json'), '{}') + mockCheckIgnore(['.env']) + + await expect(resolveWorktreeIncludePaths(repo)).resolves.toEqual(['.env']) + }) + + it('skips a listed path that is absent from the primary checkout', async () => { + writeInclude('.env\nnode_modules\n') + writeFileSync(join(repo, '.env'), 'A=1') + mockCheckIgnore(['.env']) + + // node_modules absent (not installed yet) → not stat-able → not requested from git. + await expect(resolveWorktreeIncludePaths(repo)).resolves.toEqual(['.env']) + }) + + it('resolves a gitignored symlink entry without following it', async () => { + writeInclude('.env\n') + writeFileSync(join(repo, '.env.real'), 'A=1') + symlinkSync(join(repo, '.env.real'), join(repo, '.env')) + mockCheckIgnore(['.env']) + + await expect(resolveWorktreeIncludePaths(repo)).resolves.toEqual(['.env']) + }) + + it('skips glob and negation entries with a warning', async () => { + writeInclude('.env.*\n!.env.production\n.env\n') + writeFileSync(join(repo, '.env'), 'A=1') + mockCheckIgnore(['.env']) + + await expect(resolveWorktreeIncludePaths(repo)).resolves.toEqual(['.env']) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('unsupported')) + }) + + it('rejects traversal, absolute, and .git entries', async () => { + writeInclude('../outside\n/etc/passwd\n.git/config\n.env\n') + writeFileSync(join(repo, '.env'), 'A=1') + mockCheckIgnore(['.env']) + + await expect(resolveWorktreeIncludePaths(repo)).resolves.toEqual(['.env']) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('unsafe')) + }) + + it('resolves to [] when git fails instead of throwing', async () => { + writeInclude('.env\n') + writeFileSync(join(repo, '.env'), 'A=1') + gitExecFileAsyncMock.mockRejectedValue(new Error('git exploded')) + + await expect(resolveWorktreeIncludePaths(repo)).resolves.toEqual([]) + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('Failed to resolve'), + expect.any(Error) + ) + }) +}) diff --git a/src/main/git/worktree-include-file.ts b/src/main/git/worktree-include-file.ts new file mode 100644 index 00000000000..b387671fe24 --- /dev/null +++ b/src/main/git/worktree-include-file.ts @@ -0,0 +1,134 @@ +import { lstat, readFile } from 'node:fs/promises' +import { isAbsolute, join } from 'node:path' +import { checkIgnoredPaths } from './check-ignored-paths' +import type { GitRuntimeOptions } from './git-runtime-options' + +/** Project-level list of gitignored paths to copy into each new worktree. + * Cross-tool convention (see issue #7549). */ +export const WORKTREE_INCLUDE_FILE = '.worktreeinclude' + +// Why: a fresh worktree misses gitignored files (.env, .vscode/, config +// secrets); a repo-root .worktreeinclude names the ones to carry over. + +// Why: this is the "safe for now" subset — literal files and directories only. +// Glob (`*`/`?`) and negation (`!`) lines are skipped with a warning rather than +// silently mishandled; they can be added later without changing this contract. +const WORKTREE_INCLUDE_MAX_FILE_BYTES = 256 * 1024 +// Why: bound the work a single repo file can request; entries beyond this are ignored. +const WORKTREE_INCLUDE_MAX_ENTRIES = 1000 + +/** Parse `.worktreeinclude` into deduped, repo-root-relative literal paths. + * Blank lines and `#` comments are skipped; `\` is normalized to `/`, a `./` + * prefix and trailing `/` are stripped. Each entry is anchored to the repo + * root (no implicit match-at-any-depth). */ +export function parseWorktreeIncludeFile(content: string): string[] { + const seen = new Set() + const entries: string[] = [] + for (const rawLine of content.split(/\r?\n/)) { + const line = rawLine.trim() + if (!line || line.startsWith('#')) { + continue + } + const normalized = line.replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, '') + if (!normalized || seen.has(normalized)) { + continue + } + seen.add(normalized) + entries.push(normalized) + } + return entries +} + +function isUnsupportedPattern(entry: string): boolean { + return entry.startsWith('!') || entry.includes('*') || entry.includes('?') +} + +function isSafeIncludePath(relativePath: string): boolean { + if (!relativePath || isAbsolute(relativePath)) { + return false + } + const segments = relativePath.split('/') + return !segments.includes('..') && !segments.includes('') && segments[0] !== '.git' +} + +async function readWorktreeIncludeFile(repoPath: string): Promise { + const includePath = join(repoPath, WORKTREE_INCLUDE_FILE) + try { + const stats = await lstat(includePath) + if (!stats.isFile() || stats.size > WORKTREE_INCLUDE_MAX_FILE_BYTES) { + return null + } + return await readFile(includePath, 'utf8') + } catch { + return null + } +} + +/** Resolve `.worktreeinclude` at the repo root to concrete repo-relative paths + * to copy into a new worktree. + * + * Only paths that exist in the primary checkout **and** are gitignored are + * returned — tracked files are already present in a fresh worktree, and + * copying untracked-but-unignored files would create spurious diffs. + * + * Never throws: any read/parse/git failure resolves to `[]` so worktree + * creation is never blocked by this file. */ +export async function resolveWorktreeIncludePaths( + repoPath: string, + options: GitRuntimeOptions = {} +): Promise { + try { + const content = await readWorktreeIncludeFile(repoPath) + if (content === null) { + return [] + } + + const candidates: string[] = [] + for (const entry of parseWorktreeIncludeFile(content)) { + if (candidates.length >= WORKTREE_INCLUDE_MAX_ENTRIES) { + console.warn( + `[worktree-include] ${WORKTREE_INCLUDE_FILE} lists more than ${WORKTREE_INCLUDE_MAX_ENTRIES} entries; ignoring the rest` + ) + break + } + if (isUnsupportedPattern(entry)) { + // Glob and negation are not supported yet; skip loudly so the entry isn't silently mis-copied. + console.warn( + `[worktree-include] Skipping unsupported ${WORKTREE_INCLUDE_FILE} pattern "${entry}" (only literal files and directories are supported)` + ) + continue + } + if (!isSafeIncludePath(entry)) { + console.warn(`[worktree-include] Skipping unsafe ${WORKTREE_INCLUDE_FILE} path "${entry}"`) + continue + } + candidates.push(entry) + } + if (candidates.length === 0) { + return [] + } + + // Keep only entries present in the primary checkout — a listed but absent + // path (e.g. node_modules before install) has nothing to copy. + const existing: string[] = [] + for (const relativePath of candidates) { + try { + await lstat(join(repoPath, relativePath)) + existing.push(relativePath) + } catch { + // Absent in the primary checkout — nothing to copy. + } + } + if (existing.length === 0) { + return [] + } + + // Why: enforce the gitignored-only contract (issue #7549) — never duplicate + // tracked files or surface unignored ones as spurious worktree diffs. + const ignored = new Set(await checkIgnoredPaths(repoPath, existing, options)) + return existing.filter((relativePath) => ignored.has(relativePath)).sort() + } catch (error) { + console.warn(`[worktree-include] Failed to resolve ${WORKTREE_INCLUDE_FILE} paths:`, error) + return [] + } +} diff --git a/src/main/ipc/worktree-apfs-clone.ts b/src/main/ipc/worktree-apfs-clone.ts new file mode 100644 index 00000000000..2d3b13573ab --- /dev/null +++ b/src/main/ipc/worktree-apfs-clone.ts @@ -0,0 +1,189 @@ +import { execFile, type ExecFileOptions } from 'node:child_process' +import { randomUUID } from 'node:crypto' +import { mkdir, stat, rm, link, rmdir, chmod } from 'node:fs/promises' +import { dirname, resolve, sep } from 'node:path' +import { promisify } from 'node:util' + +type ExecFileAsync = ( + file: string, + args: readonly string[], + options?: Pick +) => Promise<{ stdout: string; stderr: string }> + +const execFileAsync = promisify(execFile) as ExecFileAsync +// Why: bound the df/diskutil volume probes so a wedged mount can't stall worktree creation. +const APFS_FILESYSTEM_PROBE_TIMEOUT_MS = 5_000 + +export type ApfsCloneDeps = { + execFileAsync: ExecFileAsync + randomUUID: () => string +} + +export const defaultApfsCloneDeps: ApfsCloneDeps = { + execFileAsync, + randomUUID +} + +type DarwinFilesystemInfo = { + device: string + filesystemName: string +} + +/** Per-materialization cache keyed by `stat().dev`. Copying N `.worktreeinclude` + * paths would otherwise re-run df+diskutil per path (4 subprocesses each) even + * though source and worktree almost always share one volume; caching collapses + * that to one probe per distinct volume. */ +export type DarwinFilesystemCache = Map> + +export class ApfsCloneUnavailableError extends Error { + constructor(message: string) { + super(message) + this.name = 'ApfsCloneUnavailableError' + } +} + +export class WorktreeLinkedPathTargetExistsError extends Error { + constructor(target: string) { + super(`Worktree linked path target already exists: ${target}`) + this.name = 'WorktreeLinkedPathTargetExistsError' + } +} + +function isAlreadyExistsError(error: unknown): boolean { + return (error as { code?: unknown })?.code === 'EEXIST' +} + +async function getDarwinFilesystemInfo( + path: string, + deps: ApfsCloneDeps +): Promise { + const { stdout: dfOutput } = await deps.execFileAsync('/bin/df', ['-P', path], { + timeout: APFS_FILESYSTEM_PROBE_TIMEOUT_MS + }) + const device = dfOutput.trim().split(/\r?\n/)[1]?.trim().split(/\s+/)[0] + if (!device) { + throw new Error(`Could not resolve filesystem device for ${path}`) + } + const { stdout: diskutilOutput } = await deps.execFileAsync( + '/usr/sbin/diskutil', + ['info', '-plist', device], + { timeout: APFS_FILESYSTEM_PROBE_TIMEOUT_MS } + ) + const filesystemNameMatch = /FilesystemName<\/key>\s*([^<]+)<\/string>/u.exec( + diskutilOutput + ) + return { + device, + filesystemName: filesystemNameMatch?.[1] ?? '' + } +} + +async function getCachedDarwinFilesystemInfo( + path: string, + deps: ApfsCloneDeps, + cache: DarwinFilesystemCache +): Promise { + const deviceId = (await stat(path)).dev + const cached = cache.get(deviceId) + if (cached) { + return cached + } + // Why: cache the pending (or rejected) probe so every path on this volume + // reuses one df+diskutil pair instead of respawning them per copy. + const pending = getDarwinFilesystemInfo(path, deps) + cache.set(deviceId, pending) + return pending +} + +async function assertSameApfsVolume( + source: string, + target: string, + deps: ApfsCloneDeps, + cache: DarwinFilesystemCache +): Promise { + const [sourceInfo, targetInfo] = await Promise.all([ + getCachedDarwinFilesystemInfo(source, deps, cache), + getCachedDarwinFilesystemInfo(dirname(target), deps, cache) + ]) + if ( + sourceInfo.device !== targetInfo.device || + sourceInfo.filesystemName !== 'APFS' || + targetInfo.filesystemName !== 'APFS' + ) { + throw new ApfsCloneUnavailableError( + 'APFS clone-copy requires source and target on the same APFS volume' + ) + } +} + +async function cloneFileWithApfs( + source: string, + target: string, + deps: ApfsCloneDeps +): Promise { + const tempTarget = resolve(dirname(target), `.orca-apfs-clone-${deps.randomUUID()}`) + try { + await deps.execFileAsync('/bin/cp', ['-c', source, tempTarget]) + try { + // Why: link(2) is an atomic no-clobber publish for files; rename(2) can + // overwrite a target that appeared after the earlier existence check. + await link(tempTarget, target) + } catch (error) { + if (isAlreadyExistsError(error)) { + throw new WorktreeLinkedPathTargetExistsError(target) + } + throw error + } + } finally { + await rm(tempTarget, { force: true }).catch(() => undefined) + } +} + +async function cloneDirectoryWithApfs( + source: string, + target: string, + deps: ApfsCloneDeps +): Promise { + const sourceMode = (await stat(source)).mode & 0o777 + try { + // Why: reserve the final directory path before copying into it so a raced + // user-created directory cannot be replaced by a final rename. + await mkdir(target) + } catch (error) { + if (isAlreadyExistsError(error)) { + throw new WorktreeLinkedPathTargetExistsError(target) + } + throw error + } + + try { + // Why: the top-level directory is reserved before cp runs, so use `-n` + // to keep a raced nested file from being overwritten during the copy. + // Why: copy `source/.` into the reserved target so contents land at the + // requested path even when the source is a symlinked directory. + await deps.execFileAsync('/bin/cp', ['-n', '-c', '-R', `${source}${sep}.`, target]) + await chmod(target, sourceMode) + } catch (error) { + // Why: remove only the empty reservation. If cp wrote anything, or another + // process raced files into the directory, leave it for Git/user review. + await rmdir(target).catch(() => undefined) + throw error + } +} + +export async function cloneWorktreePathWithApfs( + source: string, + target: string, + sourceIsDirectory: boolean, + deps: ApfsCloneDeps = defaultApfsCloneDeps, + filesystemCache: DarwinFilesystemCache = new Map() +): Promise { + await mkdir(dirname(target), { recursive: true }) + await assertSameApfsVolume(source, target, deps, filesystemCache) + // Why: Node's COPYFILE_FICLONE_FORCE returns ENOSYS on macOS in our runtime, + // while Darwin's cp exposes APFS clonefile via -c. Preflight the volume so + // cp's non-APFS full-copy fallback cannot surprise users. + await (sourceIsDirectory + ? cloneDirectoryWithApfs(source, target, deps) + : cloneFileWithApfs(source, target, deps)) +} diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index 7a9b2d38643..707929ee7a2 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -95,7 +95,8 @@ import { prepareWorktreePushTargetWithExec } from './worktree-push-target-setup' import { isENOENT, registerWorktreeRootsForRepo } from './filesystem-auth' -import { createWorktreeLinkedPaths } from './worktree-symlinks' +import { createWorktreeCopiedPaths, createWorktreeLinkedPaths } from './worktree-symlinks' +import { resolveWorktreeIncludePaths } from '../git/worktree-include-file' import { normalizeSparseDirectories } from './sparse-checkout-directories' import { joinWorktreeRelativePath } from '../runtime/runtime-relative-paths' import type { IFilesystemProvider } from '../providers/types' @@ -1865,7 +1866,7 @@ export async function createRemoteWorktree( }) const workspaceLineage = recordWorkspaceLineageForCreatedWorktree(store, args, worktree, now) - // Why: shared/symlink paths are local-only; remote (SSH) support needs a new relay method + auth surface, so configured symlinkPaths are ignored here. + // 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. let setup: CreateWorktreeResult['setup'] let defaultTabs: CreateWorktreeResult['defaultTabs'] @@ -2460,6 +2461,17 @@ export async function createLocalWorktree( }) } + // 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', () => + resolveWorktreeIncludePaths(repo.path, localWorktreeGitOptions) + ) + if (includePaths.length > 0) { + await timing.time('copy_worktreeinclude', async () => { + await createWorktreeCopiedPaths(repo.path, created.path, includePaths) + }) + } + // Why: the worktree's base-branch `orca.yaml` is authoritative; we don't re-gate on content parity with the primary checkout since benign divergence silently disabled setup (#1280). let setup: CreateWorktreeResult['setup'] let defaultTabs: CreateWorktreeResult['defaultTabs'] diff --git a/src/main/ipc/worktree-symlinks.test.ts b/src/main/ipc/worktree-symlinks.test.ts index cfb53effcfe..865c3f17b11 100644 --- a/src/main/ipc/worktree-symlinks.test.ts +++ b/src/main/ipc/worktree-symlinks.test.ts @@ -12,9 +12,10 @@ import { chmodSync } from 'node:fs' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { join, sep } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { + createWorktreeCopiedPaths, createWorktreeLinkedPaths, createWorktreeSymlinks, findExistingWorktreeSymlinkPaths, @@ -30,6 +31,7 @@ function createApfsCloneDeps(options: { uuid?: string onCp?: (args: readonly string[]) => void onDiskutil?: () => void + diskutilError?: Error }): ApfsCloneDepsForTest { const execFileAsync = vi.fn(async (file, args) => { if (file === '/bin/df') { @@ -41,6 +43,9 @@ function createApfsCloneDeps(options: { } } if (file === '/usr/sbin/diskutil') { + if (options.diskutilError) { + throw options.diskutilError + } options.onDiskutil?.() return { stdout: `FilesystemNameAPFS`, @@ -301,7 +306,7 @@ describe('createWorktreeSymlinks', () => { apfsCloneDeps: deps }) - expect(cpArgs).toEqual(['-n', '-c', '-R', source, worktree]) + expect(cpArgs).toEqual(['-n', '-c', '-R', `${source}${sep}.`, target]) expect(readFileSync(join(target, 'primary-marker'), 'utf8')).toBe('USER\n') expect(warn).toHaveBeenCalledWith( expect.stringContaining('[worktree-symlinks] APFS clone-copy unavailable'), @@ -389,6 +394,169 @@ describe('createWorktreeSymlinks', () => { }) }) +describe('createWorktreeCopiedPaths', () => { + let root: string + let primary: string + let worktree: string + let warn: ReturnType + let error: ReturnType + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'orca-copiedpaths-')) + 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 }) + }) + + it('copies a file so worktree edits never leak back to the primary checkout', async () => { + writeFileSync(join(primary, '.env'), 'SECRET=1\n') + + await createWorktreeCopiedPaths(primary, worktree, ['.env'], { platform: 'linux' }) + + expect(lstatSync(join(worktree, '.env')).isSymbolicLink()).toBe(false) + expect(readFileSync(join(worktree, '.env'), 'utf8')).toBe('SECRET=1\n') + writeFileSync(join(worktree, '.env'), 'SECRET=2\n') + expect(readFileSync(join(primary, '.env'), 'utf8')).toBe('SECRET=1\n') + }) + + it('copies a directory recursively without symlinking', async () => { + mkdirSync(join(primary, '.vscode')) + writeFileSync(join(primary, '.vscode', 'settings.json'), '{}') + + await createWorktreeCopiedPaths(primary, worktree, ['.vscode'], { platform: 'linux' }) + + expect(lstatSync(join(worktree, '.vscode')).isSymbolicLink()).toBe(false) + expect(readFileSync(join(worktree, '.vscode', 'settings.json'), 'utf8')).toBe('{}') + }) + + it('creates parent directories lazily for nested paths', async () => { + mkdirSync(join(primary, 'apps', 'web'), { recursive: true }) + writeFileSync(join(primary, 'apps', 'web', '.env'), 'A=1') + + await createWorktreeCopiedPaths(primary, worktree, ['apps/web/.env'], { platform: 'linux' }) + + expect(readFileSync(join(worktree, 'apps', 'web', '.env'), 'utf8')).toBe('A=1') + }) + + // Finding 1 regression: a symlinked include entry must become an independent + // copy, not a symlink, or worktree edits would leak back into the shared target. + posixIt('dereferences a symlinked file entry so edits do not leak to the primary', async () => { + writeFileSync(join(primary, '.env.shared'), 'SECRET=1\n') + symlinkSync(join(primary, '.env.shared'), join(primary, '.env')) + + await createWorktreeCopiedPaths(primary, worktree, ['.env'], { platform: 'linux' }) + + expect(lstatSync(join(worktree, '.env')).isSymbolicLink()).toBe(false) + writeFileSync(join(worktree, '.env'), 'SECRET=2\n') + expect(readFileSync(join(primary, '.env.shared'), 'utf8')).toBe('SECRET=1\n') + }) + + posixIt('dereferences a symlinked directory entry into an independent copy', async () => { + mkdirSync(join(primary, '.cache-real')) + writeFileSync(join(primary, '.cache-real', 'f'), 'ORIG\n') + symlinkSync(join(primary, '.cache-real'), join(primary, '.cache'), 'dir') + + await createWorktreeCopiedPaths(primary, worktree, ['.cache'], { platform: 'linux' }) + + expect(lstatSync(join(worktree, '.cache')).isSymbolicLink()).toBe(false) + writeFileSync(join(worktree, '.cache', 'f'), 'CHANGED\n') + expect(readFileSync(join(primary, '.cache-real', 'f'), 'utf8')).toBe('ORIG\n') + }) + + it('preserves a pre-existing target in the worktree (no clobber)', async () => { + writeFileSync(join(primary, '.env'), 'SECRET=1\n') + writeFileSync(join(worktree, '.env'), 'MINE=1\n') + + await createWorktreeCopiedPaths(primary, worktree, ['.env'], { platform: 'linux' }) + + expect(readFileSync(join(worktree, '.env'), 'utf8')).toBe('MINE=1\n') + }) + + it('rejects traversal and treats absolute paths as repo-relative', async () => { + writeFileSync(join(root, 'outside.txt'), 'OUT=1') + + await createWorktreeCopiedPaths(primary, worktree, ['../outside.txt', '/etc/passwd'], { + platform: 'linux' + }) + + expect(existsSync(join(worktree, 'outside.txt'))).toBe(false) + // `/etc/passwd` → `etc/passwd`, absent from primary → silently skipped. + expect(existsSync(join(worktree, 'etc'))).toBe(false) + expect(warn).toHaveBeenCalledTimes(1) + }) + + it('falls back to a real copy, not a symlink, when macOS clone-copy is unavailable', async () => { + writeFileSync(join(primary, '.env'), 'SECRET=1\n') + const cloneWorktreePath = vi.fn(async () => { + throw new Error('clonefile unsupported') + }) + + await createWorktreeCopiedPaths(primary, worktree, ['.env'], { + platform: 'darwin', + cloneWorktreePath + }) + + expect(lstatSync(join(worktree, '.env')).isSymbolicLink()).toBe(false) + expect(readFileSync(join(worktree, '.env'), 'utf8')).toBe('SECRET=1\n') + }) + + it('uses APFS clone-copy for configured paths on macOS', async () => { + writeFileSync(join(primary, '.env'), 'SECRET=1\n') + const cloneWorktreePath = vi.fn(async (_source: string, target: string) => { + writeFileSync(target, 'CLONED=1\n') + }) + + await createWorktreeCopiedPaths(primary, worktree, ['.env'], { + platform: 'darwin', + cloneWorktreePath + }) + + expect(cloneWorktreePath).toHaveBeenCalledWith( + join(primary, '.env'), + join(worktree, '.env'), + false + ) + expect(readFileSync(join(worktree, '.env'), 'utf8')).toBe('CLONED=1\n') + }) + + // Perf: the df+diskutil volume probe must not scale with the number of copied + // paths — one probe per distinct volume, cached across the materialization. + it('probes each APFS volume once regardless of how many paths are copied', async () => { + for (const name of ['.env', '.env.local', 'config.json', 'secrets.json']) { + writeFileSync(join(primary, name), `${name}\n`) + } + const deps = createApfsCloneDeps({ onCp: () => {} }) + + await createWorktreeCopiedPaths( + primary, + worktree, + ['.env', '.env.local', 'config.json', 'secrets.json'], + { platform: 'darwin', apfsCloneDeps: deps } + ) + + const execFileAsyncMock = vi.mocked(deps.execFileAsync) + const dfCalls = execFileAsyncMock.mock.calls.filter(([file]) => file === '/bin/df').length + const diskutilCalls = execFileAsyncMock.mock.calls.filter( + ([file]) => file === '/usr/sbin/diskutil' + ).length + // 4 paths would be 8 df + 8 diskutil un-cached; source+worktree share one + // tmp volume, so caching collapses this to a single probe pair. + expect(dfCalls).toBeLessThanOrEqual(2) + expect(diskutilCalls).toBeLessThanOrEqual(2) + // The copies themselves still happen per path. + expect(execFileAsyncMock.mock.calls.filter(([file]) => file === '/bin/cp')).toHaveLength(4) + }) +}) + describe('removeWorktreeSymlinks', () => { let root: string let primary: string diff --git a/src/main/ipc/worktree-symlinks.ts b/src/main/ipc/worktree-symlinks.ts index 98e74e753fb..fb81cc37df4 100644 --- a/src/main/ipc/worktree-symlinks.ts +++ b/src/main/ipc/worktree-symlinks.ts @@ -1,25 +1,13 @@ -import { execFile } from 'node:child_process' -import { randomUUID } from 'node:crypto' -import { symlink, mkdir, stat, lstat, unlink, rm, link, rmdir, chmod } from 'node:fs/promises' +import { symlink, mkdir, stat, lstat, unlink, cp, realpath } from 'node:fs/promises' import { dirname, isAbsolute, resolve } from 'node:path' -import { promisify } from 'node:util' - -type ExecFileAsync = ( - file: string, - args: readonly string[] -) => Promise<{ stdout: string; stderr: string }> - -const execFileAsync = promisify(execFile) as ExecFileAsync - -type ApfsCloneDeps = { - execFileAsync: ExecFileAsync - randomUUID: () => string -} - -const defaultApfsCloneDeps: ApfsCloneDeps = { - execFileAsync, - randomUUID -} +import { + ApfsCloneUnavailableError, + cloneWorktreePathWithApfs, + defaultApfsCloneDeps, + WorktreeLinkedPathTargetExistsError, + type ApfsCloneDeps, + type DarwinFilesystemCache +} from './worktree-apfs-clone' type WorktreeLinkedPathOptions = { platform?: NodeJS.Platform @@ -27,6 +15,11 @@ type WorktreeLinkedPathOptions = { apfsCloneDeps?: ApfsCloneDeps } +// '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' + type SafeRelativePathResult = | { safe: true @@ -36,29 +29,6 @@ type SafeRelativePathResult = safe: false } -type DarwinFilesystemInfo = { - device: string - filesystemName: string -} - -class ApfsCloneUnavailableError extends Error { - constructor(message: string) { - super(message) - this.name = 'ApfsCloneUnavailableError' - } -} - -class WorktreeLinkedPathTargetExistsError extends Error { - constructor(target: string) { - super(`Worktree linked path target already exists: ${target}`) - this.name = 'WorktreeLinkedPathTargetExistsError' - } -} - -function isAlreadyExistsError(error: unknown): boolean { - return (error as { code?: unknown })?.code === 'EEXIST' -} - function getSafeRelativePath(rawPath: string): SafeRelativePathResult { // Why: strip leading separators (both `/` and `\`) before the guard so // Windows-style input like `\foo` is normalized the same way POSIX `/foo` @@ -74,17 +44,6 @@ function getSafeRelativePath(rawPath: string): SafeRelativePathResult { return { safe: true, rel } } -async function targetExists(target: string): Promise { - try { - // Why: use lstat so a pre-existing symlink (including a broken one whose - // source has moved) is detected and skipped instead of overwritten. - await lstat(target) - return true - } catch { - return false - } -} - async function symlinkWorktreePath( source: string, target: string, @@ -98,115 +57,11 @@ async function symlinkWorktreePath( await symlink(source, target, sourceIsDirectory ? 'dir' : 'file') } -async function getDarwinFilesystemInfo( - path: string, - deps: ApfsCloneDeps -): Promise { - const { stdout: dfOutput } = await deps.execFileAsync('/bin/df', ['-P', path]) - const device = dfOutput.trim().split(/\r?\n/)[1]?.trim().split(/\s+/)[0] - if (!device) { - throw new Error(`Could not resolve filesystem device for ${path}`) - } - const { stdout: diskutilOutput } = await deps.execFileAsync('/usr/sbin/diskutil', [ - 'info', - '-plist', - device - ]) - const filesystemNameMatch = /FilesystemName<\/key>\s*([^<]+)<\/string>/u.exec( - diskutilOutput - ) - return { - device, - filesystemName: filesystemNameMatch?.[1] ?? '' - } -} - -async function assertSameApfsVolume( - source: string, - target: string, - deps: ApfsCloneDeps -): Promise { - const [sourceInfo, targetInfo] = await Promise.all([ - getDarwinFilesystemInfo(source, deps), - getDarwinFilesystemInfo(dirname(target), deps) - ]) - if ( - sourceInfo.device !== targetInfo.device || - sourceInfo.filesystemName !== 'APFS' || - targetInfo.filesystemName !== 'APFS' - ) { - throw new ApfsCloneUnavailableError( - 'APFS clone-copy requires source and target on the same APFS volume' - ) - } -} - -async function cloneFileWithApfs( - source: string, - target: string, - deps: ApfsCloneDeps -): Promise { - const tempTarget = resolve(dirname(target), `.orca-apfs-clone-${deps.randomUUID()}`) - try { - await deps.execFileAsync('/bin/cp', ['-c', source, tempTarget]) - try { - // Why: link(2) is an atomic no-clobber publish for files; rename(2) can - // overwrite a target that appeared after the earlier existence check. - await link(tempTarget, target) - } catch (error) { - if (isAlreadyExistsError(error)) { - throw new WorktreeLinkedPathTargetExistsError(target) - } - throw error - } - } finally { - await rm(tempTarget, { force: true }).catch(() => undefined) - } -} - -async function cloneDirectoryWithApfs( - source: string, - target: string, - deps: ApfsCloneDeps -): Promise { - const sourceMode = (await stat(source)).mode & 0o777 - try { - // Why: reserve the final directory path before copying into it so a raced - // user-created directory cannot be replaced by a final rename. - await mkdir(target) - } catch (error) { - if (isAlreadyExistsError(error)) { - throw new WorktreeLinkedPathTargetExistsError(target) - } - throw error - } - - try { - // Why: the top-level directory is reserved before cp runs, so use `-n` - // to keep a raced nested file from being overwritten during the copy. - await deps.execFileAsync('/bin/cp', ['-n', '-c', '-R', source, dirname(target)]) - await chmod(target, sourceMode) - } catch (error) { - // Why: remove only the empty reservation. If cp wrote anything, or another - // process raced files into the directory, leave it for Git/user review. - await rmdir(target).catch(() => undefined) - throw error - } -} - -async function cloneWorktreePathWithApfs( - source: string, - target: string, - sourceIsDirectory: boolean, - deps: ApfsCloneDeps = defaultApfsCloneDeps -): Promise { - const targetParent = dirname(target) - await mkdir(targetParent, { recursive: true }) - await assertSameApfsVolume(source, target, deps) - // Why: Node's COPYFILE_FICLONE_FORCE returns ENOSYS on macOS in our runtime, - // while Darwin's cp exposes APFS clonefile via -c. Preflight the volume so - // cp's non-APFS full-copy fallback cannot surprise users. - await (sourceIsDirectory ? cloneDirectoryWithApfs : cloneFileWithApfs)(source, target, deps) +async function copyWorktreePath(source: string, target: string): Promise { + await mkdir(dirname(target), { recursive: true }) + // Why: force=false + errorOnExist=false skips (not clobbers) anything a racing + // process placed at the target after the earlier existence preflight. + await cp(source, target, { recursive: true, force: false, errorOnExist: false }) } async function createWorktreeLinkedPath( @@ -214,9 +69,16 @@ async function createWorktreeLinkedPath( target: string, sourceIsDirectory: boolean, sourceIsSymbolicLink: boolean, - options: WorktreeLinkedPathOptions + mode: WorktreeMaterializeMode, + options: WorktreeLinkedPathOptions, + apfsFilesystemCache: DarwinFilesystemCache ): Promise { - if (options.platform === 'darwin' && !sourceIsSymbolicLink) { + // Why: copy mode promises each worktree an independent copy; copying the + // symlink itself would recreate a link to the shared target, so edits in the + // 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')) { try { const cloneWorktreePath = options.cloneWorktreePath ?? @@ -225,32 +87,52 @@ async function createWorktreeLinkedPath( cloneSource, cloneTarget, cloneSourceIsDirectory, - options.apfsCloneDeps ?? defaultApfsCloneDeps + options.apfsCloneDeps ?? defaultApfsCloneDeps, + apfsFilesystemCache )) - await cloneWorktreePath(source, target, sourceIsDirectory) + await cloneWorktreePath(copySource, target, sourceIsDirectory) return } catch (error) { if (error instanceof WorktreeLinkedPathTargetExistsError) { return } // Why: APFS clone-copy can fail across volumes or on non-APFS disks. - // Fall back to the historical symlink behavior without touching any - // target path that may have appeared after our preflight. + // Fall back per mode without touching any target path that may have + // appeared after our preflight. if (!(error instanceof ApfsCloneUnavailableError)) { console.warn(`[worktree-symlinks] APFS clone-copy unavailable for "${target}":`, error) } } } + if (mode === 'copy') { + await copyWorktreePath(copySource, target) + return + } await symlinkWorktreePath(source, target, sourceIsDirectory) } -export async function createWorktreeLinkedPaths( +async function targetExists(target: string): Promise { + try { + // Why: lstat so a pre-existing symlink (even a broken one) is detected and + // preserved rather than overwritten. + await lstat(target) + return true + } catch { + return false + } +} + +async function materializeWorktreePaths( primaryPath: string, worktreePath: string, paths: readonly string[], + mode: WorktreeMaterializeMode, options: WorktreeLinkedPathOptions = {} ): Promise { const effectiveOptions = { platform: process.platform, ...options } + // Why: one df+diskutil probe per distinct volume for the whole materialization, + // not per copied path — see DarwinFilesystemCache. + const apfsFilesystemCache: DarwinFilesystemCache = new Map() for (const rawPath of paths) { const safePath = getSafeRelativePath(rawPath) @@ -287,7 +169,9 @@ export async function createWorktreeLinkedPaths( target, sourceIsDirectory, sourceIsSymbolicLink, - effectiveOptions + mode, + effectiveOptions, + apfsFilesystemCache ) } catch (error) { console.error( @@ -298,6 +182,28 @@ export async function createWorktreeLinkedPaths( } } +export async function createWorktreeLinkedPaths( + primaryPath: string, + worktreePath: string, + paths: readonly string[], + options: WorktreeLinkedPathOptions = {} +): Promise { + await materializeWorktreePaths(primaryPath, worktreePath, paths, 'link', options) +} + +/** Copy `.worktreeinclude`-resolved paths from the primary checkout into a + * freshly-created worktree. Same per-path failure isolation as + * createWorktreeLinkedPaths, but the non-APFS fallback is a real copy, never a + * symlink: the convention promises each worktree its own private copy. */ +export async function createWorktreeCopiedPaths( + primaryPath: string, + worktreePath: string, + paths: readonly string[], + options: WorktreeLinkedPathOptions = {} +): Promise { + await materializeWorktreePaths(primaryPath, worktreePath, paths, 'copy', 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/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index 5cd10e498c6..67b197e91a1 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -175,6 +175,7 @@ vi.mock('../providers/ssh-filesystem-dispatch', () => ({ })) vi.mock('./worktree-symlinks', () => ({ + createWorktreeCopiedPaths: vi.fn(), createWorktreeLinkedPaths: vi.fn(), findExistingWorktreeSymlinkPaths: findExistingWorktreeSymlinkPathsMock, removeWorktreeLinkedPaths: removeWorktreeLinkedPathsMock @@ -1138,6 +1139,7 @@ describe('registerWorktreeHandlers', () => { expect.arrayContaining([ 'git_worktree_add', 'list_created_worktree', + 'resolve_worktreeinclude', 'prepare_setup', 'spawn_startup_terminal' ]) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 95578891133..0029ed696bd 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -127,6 +127,7 @@ const findExistingWorktreeSymlinkPathsMock = vi.hoisted(() => vi.fn()) const resolveLocalGitUsernameMock = vi.hoisted(() => vi.fn(async () => '')) vi.mock('../ipc/worktree-symlinks', () => ({ + createWorktreeCopiedPaths: vi.fn(), createWorktreeLinkedPaths: vi.fn(), findExistingWorktreeSymlinkPaths: findExistingWorktreeSymlinkPathsMock, removeWorktreeLinkedPaths: removeWorktreeLinkedPathsMock diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index ab3fc959a2b..48920729504 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -787,10 +787,12 @@ import { } from '../../shared/constants' import { listRepoWorktrees } from '../repo-worktrees' import { + createWorktreeCopiedPaths, createWorktreeLinkedPaths, findExistingWorktreeSymlinkPaths, removeWorktreeLinkedPaths } from '../ipc/worktree-symlinks' +import { resolveWorktreeIncludePaths } from '../git/worktree-include-file' import { deleteWorktreeHistoryDir } from '../terminal-history' import { cleanupUnusedWorktreePushTargetRemote, @@ -18917,8 +18919,19 @@ export class OrcaRuntimeService { warnings: lineageWarnings } = this.recordCreatedWorktreeLineage(worktree, lineageResolution) - if (repo.symlinkPaths && repo.symlinkPaths.length > 0) { - await createWorktreeLinkedPaths(repo.path, created.path, repo.symlinkPaths) + const symlinkPaths = repo.symlinkPaths ?? [] + if (symlinkPaths.length > 0) { + await createWorktreeLinkedPaths(repo.path, created.path, symlinkPaths) + } + + // 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( + repo.path, + localWorktreeGitOptions + ) + if (worktreeIncludePaths.length > 0) { + await createWorktreeCopiedPaths(repo.path, created.path, worktreeIncludePaths) } let setup: CreateWorktreeResult['setup']