feat(worktrees): copy project-level .worktreeinclude paths into new worktrees

Read .worktreeinclude at the repo root (gitignore syntax) and copy matching
gitignored paths from the primary checkout into each newly created local
worktree, so .env and other local config carry over with zero per-user setup.

- Literal patterns resolve by direct stat; globs match against
  ls-files --others --ignored --exclude-standard --directory (collapsed
  dirs keep huge repos fast); every candidate is re-verified with
  check-ignore so tracked or unignored files are never copied.
- Copy semantics, never symlink: APFS clone-copy on macOS, real copy
  elsewhere, so each worktree owns its files (unlike repo.symlinkPaths,
  which it merges with rather than replaces).
- Failures never block worktree creation.
- Remote (SSH) creation skips it, same as symlinkPaths.
- Split APFS clone helpers into worktree-apfs-clone.ts (max-lines).

Closes #7549
This commit is contained in:
Brennan Benson
2026-07-21 16:03:31 -07:00
parent 54c1ec5e7a
commit 4471e8a2e6
9 changed files with 760 additions and 158 deletions
+158
View File
@@ -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()
})
})
+259
View File
@@ -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<GitignoredEntry[]> {
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<string | null> {
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<string[]> {
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<string, GitignoredEntry>()
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 []
}
}
+156
View File
@@ -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<DarwinFilesystemInfo> {
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 = /<key>FilesystemName<\/key>\s*<string>([^<]+)<\/string>/u.exec(
diskutilOutput
)
return {
device,
filesystemName: filesystemNameMatch?.[1] ?? ''
}
}
async function assertSameApfsVolume(
source: string,
target: string,
deps: ApfsCloneDeps
): Promise<void> {
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<void> {
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<void> {
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<void> {
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)
}
+12 -2
View File
@@ -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']
+111
View File
@@ -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<typeof vi.spyOn>
let error: ReturnType<typeof vi.spyOn>
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
+50 -156
View File
@@ -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<DarwinFilesystemInfo> {
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 = /<key>FilesystemName<\/key>\s*<string>([^<]+)<\/string>/u.exec(
diskutilOutput
)
return {
device,
filesystemName: filesystemNameMatch?.[1] ?? ''
}
}
async function assertSameApfsVolume(
source: string,
target: string,
deps: ApfsCloneDeps
): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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.
+1
View File
@@ -175,6 +175,7 @@ vi.mock('../providers/ssh-filesystem-dispatch', () => ({
}))
vi.mock('./worktree-symlinks', () => ({
createWorktreeCopiedPaths: vi.fn(),
createWorktreeLinkedPaths: vi.fn(),
findExistingWorktreeSymlinkPaths: findExistingWorktreeSymlinkPathsMock,
removeWorktreeLinkedPaths: removeWorktreeLinkedPathsMock
+1
View File
@@ -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
+12
View File
@@ -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