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..eb4cf86fe70 --- /dev/null +++ b/src/main/git/worktree-include-file.test.ts @@ -0,0 +1,158 @@ +import { mkdtempSync, mkdirSync, 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 { parseWorktreeIncludePatterns, resolveWorktreeIncludePaths } from './worktree-include-file' +import { gitExecFileAsync } from './runner' + +vi.mock('./runner', () => ({ + gitExecFileAsync: vi.fn() +})) + +const gitExecFileAsyncMock = vi.mocked(gitExecFileAsync) + +/** Dispatch on subcommand: ls-files returns `entries`; check-ignore echoes back + * every stdin path present in `ignored`. */ +function mockGit(options: { entries?: string[]; ignored?: string[] }): void { + gitExecFileAsyncMock.mockImplementation(async (args, execOptions) => { + if (args.includes('ls-files')) { + return { stdout: (options.entries ?? []).map((entry) => `${entry}\0`).join(''), stderr: '' } + } + if (args.includes('check-ignore')) { + const requested = (execOptions.stdin ?? '').split('\0').filter(Boolean) + const ignored = new Set(options.ignored ?? requested) + const matched = requested.filter((path) => ignored.has(path)) + if (matched.length === 0) { + throw Object.assign(new Error('no matches'), { code: 1 }) + } + return { stdout: matched.map((path) => `${path}\0`).join(''), stderr: '' } + } + throw new Error(`Unexpected git args: ${args.join(' ')}`) + }) +} + +describe('parseWorktreeIncludePatterns', () => { + it('skips blank lines and comments', () => { + const patterns = parseWorktreeIncludePatterns('# secrets\n\n.env\n \n# more\n.env.local\n') + expect(patterns.map((pattern) => pattern.body)).toEqual(['.env', '.env.local']) + }) + + it('parses negation, anchoring, and dir-only markers', () => { + const [negated, anchored, dirOnly, bare] = parseWorktreeIncludePatterns( + '!.env.production\n/config/secrets.json\n.vscode/\nbuild\n' + ) + expect(negated).toMatchObject({ negated: true, body: '.env.production', anchored: false }) + expect(anchored).toMatchObject({ negated: false, body: 'config/secrets.json', anchored: true }) + expect(dirOnly).toMatchObject({ dirOnly: true, body: '.vscode', anchored: false }) + expect(bare).toMatchObject({ dirOnly: false, anchored: false, hasGlob: false }) + }) + + it('marks glob patterns and compiles their matcher', () => { + const [glob] = parseWorktreeIncludePatterns('.env.*\n') + expect(glob.hasGlob).toBe(true) + expect(glob.regExp?.test('.env.local')).toBe(true) + expect(glob.regExp?.test('.envrc')).toBe(false) + }) +}) + +describe('resolveWorktreeIncludePaths', () => { + let repo: string + + beforeEach(() => { + repo = mkdtempSync(join(tmpdir(), 'orca-worktreeinclude-')) + gitExecFileAsyncMock.mockReset() + }) + + afterEach(() => { + 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 literal patterns to existing gitignored paths', async () => { + writeInclude('.env\nconfig/secrets.json\nmissing.txt\n') + writeFileSync(join(repo, '.env'), 'A=1') + mkdirSync(join(repo, 'config')) + writeFileSync(join(repo, 'config', 'secrets.json'), '{}') + mockGit({ ignored: ['.env', 'config/secrets.json'] }) + + await expect(resolveWorktreeIncludePaths(repo)).resolves.toEqual([ + '.env', + 'config/secrets.json' + ]) + // Literal-only files never pay for a full gitignored enumeration. + expect(gitExecFileAsyncMock.mock.calls.every(([args]) => !args.includes('ls-files'))).toBe(true) + }) + + it('drops listed paths that are not actually gitignored', async () => { + writeInclude('.env\ntracked.json\n') + writeFileSync(join(repo, '.env'), 'A=1') + writeFileSync(join(repo, 'tracked.json'), '{}') + mockGit({ ignored: ['.env'] }) + + await expect(resolveWorktreeIncludePaths(repo)).resolves.toEqual(['.env']) + }) + + it('expands glob patterns against collapsed gitignored entries', async () => { + writeInclude('.env.*\n') + mockGit({ + entries: ['.env.local', '.env.production', '.envrc', 'node_modules/', 'apps/web/.env.local'], + ignored: ['.env.local', '.env.production', 'apps/web/.env.local'] + }) + + // Unanchored patterns match by basename at any depth, like gitignore. + await expect(resolveWorktreeIncludePaths(repo)).resolves.toEqual([ + '.env.local', + '.env.production', + 'apps/web/.env.local' + ]) + }) + + it('honors dir-only patterns against directory entries', async () => { + writeInclude('logs/\n') + writeFileSync(join(repo, 'logs'), 'not a dir') + mockGit({ ignored: [] }) + + await expect(resolveWorktreeIncludePaths(repo)).resolves.toEqual([]) + rmSync(join(repo, 'logs')) + mkdirSync(join(repo, 'logs')) + mockGit({ ignored: ['logs'] }) + await expect(resolveWorktreeIncludePaths(repo)).resolves.toEqual(['logs']) + }) + + it('applies negations with last-match-wins semantics', async () => { + writeInclude('.env.*\n!.env.production\n') + mockGit({ + entries: ['.env.local', '.env.production'], + ignored: ['.env.local', '.env.production'] + }) + + await expect(resolveWorktreeIncludePaths(repo)).resolves.toEqual(['.env.local']) + }) + + it('rejects traversal, absolute, and .git patterns', async () => { + writeInclude('../outside\n/etc/passwd\n.git/config\n.env\n') + writeFileSync(join(repo, '.env'), 'A=1') + mockGit({ ignored: ['.env'] }) + + await expect(resolveWorktreeIncludePaths(repo)).resolves.toEqual(['.env']) + }) + + it('resolves to [] when git fails instead of throwing', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + writeInclude('.env\n') + writeFileSync(join(repo, '.env'), 'A=1') + gitExecFileAsyncMock.mockRejectedValue(new Error('git exploded')) + + await expect(resolveWorktreeIncludePaths(repo)).resolves.toEqual([]) + expect(warn).toHaveBeenCalled() + warn.mockRestore() + }) +}) diff --git a/src/main/git/worktree-include-file.ts b/src/main/git/worktree-include-file.ts new file mode 100644 index 00000000000..8d3baf71e51 --- /dev/null +++ b/src/main/git/worktree-include-file.ts @@ -0,0 +1,259 @@ +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' +import { gitOptionsForWorktree } from './git-runtime-options' +import { gitExecFileAsync } from './runner' + +/** Project-level list of gitignored paths to copy into each new worktree. + * Cross-tool convention (gitignore syntax); see issue #7549. */ +export const WORKTREE_INCLUDE_FILE = '.worktreeinclude' + +// Why: reading the include file must never delay worktree creation on a wedged +// filesystem or a pathological repo; bail and create the worktree without it. +const WORKTREE_INCLUDE_GIT_TIMEOUT_MS = 15_000 +const WORKTREE_INCLUDE_MAX_FILE_BYTES = 256 * 1024 + +type WorktreeIncludePattern = { + negated: boolean + /** Pattern with `!`, leading `/`, and trailing `/` stripped. */ + body: string + dirOnly: boolean + anchored: boolean + hasGlob: boolean + regExp: RegExp | null +} + +export function parseWorktreeIncludePatterns(content: string): WorktreeIncludePattern[] { + const patterns: WorktreeIncludePattern[] = [] + for (const rawLine of content.split(/\r?\n/)) { + const line = rawLine.trim() + if (!line || line.startsWith('#')) { + continue + } + const negated = line.startsWith('!') + const withoutNegation = negated ? line.slice(1) : line + const dirOnly = withoutNegation.endsWith('/') + // Why: gitignore semantics — a slash anywhere except the end anchors the + // pattern to the repo root; a bare name matches at any depth. + const trimmed = withoutNegation.replace(/^\//, '').replace(/\/+$/, '') + if (!trimmed) { + continue + } + const anchored = withoutNegation.startsWith('/') || trimmed.includes('/') + const hasGlob = /[*?]/.test(trimmed) + patterns.push({ + negated, + body: trimmed, + dirOnly, + anchored, + hasGlob, + regExp: hasGlob ? gitignoreGlobToRegExp(trimmed) : null + }) + } + return patterns +} + +// Why: `[...]` character classes are intentionally treated as literals — the +// glob subset here mirrors what git itself matches for the overwhelmingly +// common `.env.*` / `**/secrets` shapes without risking a bad user pattern +// becoming an invalid or pathological RegExp. +function gitignoreGlobToRegExp(pattern: string): RegExp | null { + let regex = '' + for (let index = 0; index < pattern.length; index++) { + const char = pattern[index] + if (char === '*') { + if (pattern[index + 1] === '*') { + regex += '.*' + index++ + // Collapse `**/` so `foo/**/bar` also matches `foo/bar`. + if (pattern[index + 1] === '/') { + regex += '/?' + index++ + } + } else { + regex += '[^/]*' + } + } else if (char === '?') { + regex += '[^/]' + } else { + regex += char.replace(/[.+^${}()|[\]\\]/, '\\$&') + } + } + try { + return new RegExp(`^${regex}$`) + } catch { + return null + } +} + +function patternMatches( + pattern: WorktreeIncludePattern, + relativePath: string, + isDirectory: boolean +): boolean { + if (pattern.dirOnly && !isDirectory) { + return false + } + // Why: like gitignore, a matched directory brings everything beneath it, so + // a path inside a matched prefix counts as matched. + const subject = pattern.anchored ? relativePath : (relativePath.split('/').at(-1) ?? relativePath) + if (!pattern.hasGlob) { + return ( + subject === pattern.body || (pattern.anchored && relativePath.startsWith(`${pattern.body}/`)) + ) + } + return pattern.regExp !== null && pattern.regExp.test(subject) +} + +function isIncludedByPatterns( + patterns: readonly WorktreeIncludePattern[], + relativePath: string, + isDirectory: boolean +): boolean { + // Why: gitignore semantics — the last matching pattern wins, so `!` lines + // can carve exceptions out of an earlier broad include. + let included = false + for (const pattern of patterns) { + if (patternMatches(pattern, relativePath, isDirectory)) { + included = !pattern.negated + } + } + return included +} + +function isSafeIncludeCandidate(relativePath: string): boolean { + if (!relativePath || isAbsolute(relativePath)) { + return false + } + const segments = relativePath.split(/[\\/]/) + return !segments.includes('..') && segments[0] !== '.git' +} + +type GitignoredEntry = { + relativePath: string + isDirectory: boolean +} + +/** Untracked gitignored files/dirs at directory granularity. `--directory` + * collapses wholly-ignored dirs (e.g. `node_modules/`) into one entry and + * prunes traversal beneath them, keeping this fast in huge repos. */ +async function listGitignoredEntries( + repoPath: string, + options: GitRuntimeOptions +): Promise { + const { stdout } = await gitExecFileAsync( + [ + '-c', + 'core.quotePath=false', + 'ls-files', + '--others', + '--ignored', + '--exclude-standard', + '--directory', + '-z' + ], + { + ...gitOptionsForWorktree(repoPath, options), + timeout: WORKTREE_INCLUDE_GIT_TIMEOUT_MS + } + ) + const entries: GitignoredEntry[] = [] + for (const rawEntry of stdout.split('\0')) { + if (!rawEntry) { + continue + } + const isDirectory = rawEntry.endsWith('/') + entries.push({ + relativePath: isDirectory ? rawEntry.replace(/\/+$/, '') : rawEntry, + isDirectory + }) + } + return entries +} + +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. + * + * Semantics match the cross-tool convention: gitignore syntax, and only paths + * that are actually gitignored are ever returned — tracked files are already + * present in a fresh worktree, and untracked-but-not-ignored files would show + * up as 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 patterns = parseWorktreeIncludePatterns(content) + if (!patterns.some((pattern) => !pattern.negated)) { + return [] + } + + const candidates = new Map() + const addCandidate = (entry: GitignoredEntry): void => { + if ( + isSafeIncludeCandidate(entry.relativePath) && + isIncludedByPatterns(patterns, entry.relativePath, entry.isDirectory) + ) { + candidates.set(entry.relativePath, entry) + } + } + + // Literal patterns resolve by direct stat so they work even for paths + // nested inside a wholly-gitignored directory that ls-files collapses. + for (const pattern of patterns) { + if (pattern.negated || pattern.hasGlob || !isSafeIncludeCandidate(pattern.body)) { + continue + } + try { + const stats = await lstat(join(repoPath, pattern.body)) + addCandidate({ relativePath: pattern.body, isDirectory: stats.isDirectory() }) + } catch { + // Absent in the primary checkout — nothing to copy. + } + } + + // Glob patterns match against the collapsed gitignored entry list; run the + // enumeration only when a glob is actually present. + if (patterns.some((pattern) => !pattern.negated && pattern.hasGlob)) { + for (const entry of await listGitignoredEntries(repoPath, options)) { + addCandidate(entry) + } + } + if (candidates.size === 0) { + return [] + } + + // Why: enforce the gitignored-only contract for literal-derived candidates + // too — a listed-but-not-ignored path must not be copied (issue #7549). + const ignored = new Set( + await checkIgnoredPaths(repoPath, Array.from(candidates.keys()), options) + ) + return Array.from(candidates.keys()) + .filter((relativePath) => ignored.has(relativePath)) + .sort() + } catch (error) { + console.warn(`[worktree-include] Failed to resolve ${WORKTREE_INCLUDE_FILE} patterns:`, 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..d7c1a08680d --- /dev/null +++ b/src/main/ipc/worktree-apfs-clone.ts @@ -0,0 +1,156 @@ +import { execFile } from 'node:child_process' +import { randomUUID } from 'node:crypto' +import { mkdir, stat, rm, link, rmdir, chmod } from 'node:fs/promises' +import { dirname, 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 + +export type ApfsCloneDeps = { + execFileAsync: ExecFileAsync + randomUUID: () => string +} + +export const defaultApfsCloneDeps: ApfsCloneDeps = { + execFileAsync, + randomUUID +} + +type DarwinFilesystemInfo = { + device: string + filesystemName: string +} + +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]) + 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 + } +} + +export 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) +} diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index 014e04a583a..8a0e59eee1b 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -94,7 +94,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' @@ -1864,7 +1865,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'] @@ -2458,6 +2459,15 @@ 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 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..378cb516bf8 100644 --- a/src/main/ipc/worktree-symlinks.test.ts +++ b/src/main/ipc/worktree-symlinks.test.ts @@ -15,6 +15,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { + createWorktreeCopiedPaths, createWorktreeLinkedPaths, createWorktreeSymlinks, findExistingWorktreeSymlinkPaths, @@ -389,6 +390,116 @@ 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') + }) + + 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') + }) +}) + 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..ee768025e2a 100644 --- a/src/main/ipc/worktree-symlinks.ts +++ b/src/main/ipc/worktree-symlinks.ts @@ -1,25 +1,12 @@ -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 } 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 +} from './worktree-apfs-clone' type WorktreeLinkedPathOptions = { platform?: NodeJS.Platform @@ -27,6 +14,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 +28,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` @@ -98,115 +67,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,6 +79,7 @@ async function createWorktreeLinkedPath( target: string, sourceIsDirectory: boolean, sourceIsSymbolicLink: boolean, + mode: WorktreeMaterializeMode, options: WorktreeLinkedPathOptions ): Promise { if (options.platform === 'darwin' && !sourceIsSymbolicLink) { @@ -234,20 +100,25 @@ async function createWorktreeLinkedPath( 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(source, target) + return + } await symlinkWorktreePath(source, target, sourceIsDirectory) } -export async function createWorktreeLinkedPaths( +async function materializeWorktreePaths( primaryPath: string, worktreePath: string, paths: readonly string[], + mode: WorktreeMaterializeMode, options: WorktreeLinkedPathOptions = {} ): Promise { const effectiveOptions = { platform: process.platform, ...options } @@ -287,6 +158,7 @@ export async function createWorktreeLinkedPaths( target, sourceIsDirectory, sourceIsSymbolicLink, + mode, effectiveOptions ) } catch (error) { @@ -298,6 +170,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 bcbc940f72e..5e57409b6f5 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 diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index d6cdb4d08d0..76eaf3f3283 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -110,6 +110,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 6480b965570..7d2737f5de3 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -715,10 +715,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, @@ -17252,6 +17254,16 @@ export class OrcaRuntimeService { await createWorktreeLinkedPaths(repo.path, created.path, repo.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'] let warning: string | undefined // Why: CLI-created worktrees do not have a renderer preview to mismatch