mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 08:03:28 +00:00
* 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 * fix(worktrees): harden worktree include copying * fix(worktrees): support nested includes on Git 2.25 * fix(worktrees): bound include copy costs * fix(worktrees): close include correctness and perf gaps * fix(worktrees): preserve included copy semantics * fix(worktrees): harden include resolution * fix(worktrees): preserve bounded include resolution * fix(worktrees): bound include filesystem resolution * fix(worktrees): harden include matching * fix(worktrees): tighten include matching and scan bounds * fix(types): use concrete filesystem stat types * fix(worktrees): harden included path materialization * perf(worktrees): stop include parsing at resolver budgets * chore(skills): refresh bundled skill manifests * refactor(worktrees): reduce .worktreeinclude to focused literal-only scope The reviewed implementation grew well past the ticket (#7549), which asks for a size-M feature that reuses existing worktree machinery. Trim back to the minimal change that solves the reported problem safely: - Resolver now supports literal files and directories only. Glob/negation lines are skipped with a warning (documented follow-up), which removes the entire user-controlled-regex ReDoS surface, the CPU/byte budgets, the git enumeration scan, and the case-sensitivity engine. The filesystem + git check-ignore handle existence and case for free. - Copy layer folded back into worktree-symlinks.ts (link/copy modes share one loop); dropped worktree-path-copy.ts, worktree-target-safety.ts, the descendant-dedup/realpath/target-parent machinery, and the per-materialization APFS filesystem cache. Kept the df/diskutil probe timeout. - Reverted unrelated changes: check-ignored-paths timeout param and the git-binary-compatibility enumeration tests. Net: -1903/+172 across the include+copy code. Behavior for the ticket's cases (.env, .env.local, .vscode/, node_modules, config/secrets.json) is unchanged; gitignored-only + copy-not-symlink semantics preserved. Closes #7549 * fix(worktrees): dereference symlinked .worktreeinclude entries + cache APFS volume probe Two issues found by review + perf audit of the copy path: - Correctness (HIGH): a listed entry that is itself a gitignored symlink was copied AS a symlink (fs.cp dereference:false), and the darwin APFS branch was skipped for all symlink sources. Editing the worktree's copy then wrote through to the shared/primary target — inverting copy-mode's 'each worktree owns its files' guarantee, and escaping the worktree entirely if the link pointed outside it. Now resolve realpath for a top-level symlink in copy mode so we copy content; nested symlinks inside a copied dir stay as-is (cp -R semantics). - Perf: assertSameApfsVolume ran df+diskutil per copied path (4 subprocesses each), so an N-entry include spawned ~4N short-lived processes on the macOS create hot path, all re-probing one volume. Add a per-materialization device-keyed cache: one probe per distinct volume (4N -> ~4). Tests: symlinked-file and symlinked-dir dereference regressions (no leak to primary); APFS volume probed once regardless of copied-path count.
641 lines
24 KiB
TypeScript
641 lines
24 KiB
TypeScript
import {
|
|
mkdtempSync,
|
|
mkdirSync,
|
|
writeFileSync,
|
|
statSync,
|
|
lstatSync,
|
|
readlinkSync,
|
|
readFileSync,
|
|
rmSync,
|
|
symlinkSync,
|
|
existsSync,
|
|
chmodSync
|
|
} from 'node:fs'
|
|
import { tmpdir } from 'node:os'
|
|
import { join, sep } from 'node:path'
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import {
|
|
createWorktreeCopiedPaths,
|
|
createWorktreeLinkedPaths,
|
|
createWorktreeSymlinks,
|
|
findExistingWorktreeSymlinkPaths,
|
|
removeWorktreeLinkedPaths,
|
|
removeWorktreeSymlinks
|
|
} from './worktree-symlinks'
|
|
|
|
type WorktreeLinkedPathOptionsForTest = NonNullable<Parameters<typeof createWorktreeLinkedPaths>[3]>
|
|
type ApfsCloneDepsForTest = NonNullable<WorktreeLinkedPathOptionsForTest['apfsCloneDeps']>
|
|
const posixIt = process.platform === 'win32' ? it.skip : it
|
|
|
|
function createApfsCloneDeps(options: {
|
|
uuid?: string
|
|
onCp?: (args: readonly string[]) => void
|
|
onDiskutil?: () => void
|
|
diskutilError?: Error
|
|
}): ApfsCloneDepsForTest {
|
|
const execFileAsync = vi.fn<ApfsCloneDepsForTest['execFileAsync']>(async (file, args) => {
|
|
if (file === '/bin/df') {
|
|
return {
|
|
stdout: `Filesystem 512-blocks Used Available Capacity Mounted on
|
|
/dev/disk3s1 100 50 50 50% /
|
|
`,
|
|
stderr: ''
|
|
}
|
|
}
|
|
if (file === '/usr/sbin/diskutil') {
|
|
if (options.diskutilError) {
|
|
throw options.diskutilError
|
|
}
|
|
options.onDiskutil?.()
|
|
return {
|
|
stdout: `<plist><dict><key>FilesystemName</key><string>APFS</string></dict></plist>`,
|
|
stderr: ''
|
|
}
|
|
}
|
|
if (file === '/bin/cp') {
|
|
options.onCp?.(args)
|
|
return { stdout: '', stderr: '' }
|
|
}
|
|
throw new Error(`Unexpected execFile command: ${file}`)
|
|
})
|
|
return {
|
|
execFileAsync,
|
|
randomUUID: () => options.uuid ?? 'test'
|
|
}
|
|
}
|
|
|
|
describe('createWorktreeSymlinks', () => {
|
|
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-symlinks-'))
|
|
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('symlinks a file from primary into the worktree at the same relative path', async () => {
|
|
writeFileSync(join(primary, '.env'), 'SECRET=1\n')
|
|
await createWorktreeSymlinks(primary, worktree, ['.env'])
|
|
|
|
const linkStat = lstatSync(join(worktree, '.env'))
|
|
expect(linkStat.isSymbolicLink()).toBe(true)
|
|
expect(readlinkSync(join(worktree, '.env'))).toBe(join(primary, '.env'))
|
|
// Following the link yields the primary's contents.
|
|
expect(statSync(join(worktree, '.env')).isFile()).toBe(true)
|
|
})
|
|
|
|
it('symlinks a directory from primary into the worktree', async () => {
|
|
mkdirSync(join(primary, 'node_modules'))
|
|
writeFileSync(join(primary, 'node_modules', 'marker'), 'installed')
|
|
await createWorktreeSymlinks(primary, worktree, ['node_modules'])
|
|
|
|
expect(lstatSync(join(worktree, 'node_modules')).isSymbolicLink()).toBe(true)
|
|
expect(statSync(join(worktree, 'node_modules', 'marker')).isFile()).toBe(true)
|
|
})
|
|
|
|
it('creates parent directories lazily for nested paths', async () => {
|
|
mkdirSync(join(primary, 'apps', 'web'), { recursive: true })
|
|
writeFileSync(join(primary, 'apps', 'web', '.env'), 'X=1\n')
|
|
await createWorktreeSymlinks(primary, worktree, ['apps/web/.env'])
|
|
|
|
expect(lstatSync(join(worktree, 'apps', 'web', '.env')).isSymbolicLink()).toBe(true)
|
|
})
|
|
|
|
it('skips entries whose source is missing in the primary checkout', async () => {
|
|
await createWorktreeSymlinks(primary, worktree, ['node_modules'])
|
|
// No link created, no throw.
|
|
expect(() => lstatSync(join(worktree, 'node_modules'))).toThrow()
|
|
expect(error).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('preserves a pre-existing target in the worktree (no clobber)', async () => {
|
|
writeFileSync(join(primary, '.env'), 'FROM_PRIMARY=1\n')
|
|
writeFileSync(join(worktree, '.env'), 'FROM_WORKTREE=1\n')
|
|
|
|
await createWorktreeSymlinks(primary, worktree, ['.env'])
|
|
|
|
// The pre-existing regular file stays; no symlink was created.
|
|
expect(lstatSync(join(worktree, '.env')).isSymbolicLink()).toBe(false)
|
|
expect(statSync(join(worktree, '.env')).isFile()).toBe(true)
|
|
})
|
|
|
|
it('does not escape the primary checkout via a leading-slash path', async () => {
|
|
// Why: the helper strips leading slashes (so `/etc/passwd` becomes the
|
|
// relative `etc/passwd`). No file is created outside the worktree, and the
|
|
// resolved source — which falls inside `primary/etc/passwd` — is missing,
|
|
// so the entry is silently skipped rather than linking to `/etc/passwd`.
|
|
await createWorktreeSymlinks(primary, worktree, ['/etc/passwd'])
|
|
|
|
expect(() => lstatSync(join(worktree, 'etc', 'passwd'))).toThrow()
|
|
expect(error).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('rejects parent-directory traversal', async () => {
|
|
await createWorktreeSymlinks(primary, worktree, ['../secrets'])
|
|
|
|
expect(warn).toHaveBeenCalledWith(
|
|
expect.stringContaining('[worktree-symlinks] Skipping unsafe path "../secrets"')
|
|
)
|
|
})
|
|
|
|
it('rejects nested traversal via ..', async () => {
|
|
await createWorktreeSymlinks(primary, worktree, ['safe/../../escape'])
|
|
|
|
expect(warn).toHaveBeenCalledWith(
|
|
expect.stringContaining('[worktree-symlinks] Skipping unsafe path "safe/../../escape"')
|
|
)
|
|
})
|
|
|
|
it('rejects traversal using backslash separators (Windows form)', async () => {
|
|
// Why: users configuring paths on Windows (or pasting a mixed-separator
|
|
// value) could bypass a POSIX-only split. The guard normalizes across
|
|
// `/` and `\` so `..\escape` and `foo\..\..\escape` both get rejected.
|
|
await createWorktreeSymlinks(primary, worktree, ['..\\escape'])
|
|
await createWorktreeSymlinks(primary, worktree, ['foo\\..\\..\\escape'])
|
|
|
|
expect(warn).toHaveBeenCalledWith(
|
|
expect.stringContaining('[worktree-symlinks] Skipping unsafe path "..\\escape"')
|
|
)
|
|
expect(warn).toHaveBeenCalledWith(
|
|
expect.stringContaining('[worktree-symlinks] Skipping unsafe path "foo\\..\\..\\escape"')
|
|
)
|
|
})
|
|
|
|
it('strips a leading slash then treats the remainder as a relative path', async () => {
|
|
writeFileSync(join(primary, '.env'), 'X=1\n')
|
|
await createWorktreeSymlinks(primary, worktree, ['/.env'])
|
|
|
|
// Leading slash is stripped in the helper; the remaining `.env` is a valid relative path.
|
|
expect(lstatSync(join(worktree, '.env')).isSymbolicLink()).toBe(true)
|
|
expect(warn).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('skips empty and whitespace-only entries', async () => {
|
|
// The helper logs an "unsafe path" warn for these; nothing gets linked.
|
|
await createWorktreeSymlinks(primary, worktree, ['', ' '])
|
|
expect(error).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('continues processing later entries after one fails', async () => {
|
|
writeFileSync(join(primary, '.env'), 'X=1\n')
|
|
writeFileSync(join(primary, 'config.json'), '{}')
|
|
|
|
await createWorktreeSymlinks(primary, worktree, [
|
|
'../escape', // rejected
|
|
'missing-source', // no source, skipped
|
|
'.env', // succeeds
|
|
'config.json' // succeeds
|
|
])
|
|
|
|
expect(lstatSync(join(worktree, '.env')).isSymbolicLink()).toBe(true)
|
|
expect(lstatSync(join(worktree, 'config.json')).isSymbolicLink()).toBe(true)
|
|
})
|
|
|
|
it('is a no-op for an empty paths list', async () => {
|
|
await createWorktreeSymlinks(primary, worktree, [])
|
|
expect(warn).not.toHaveBeenCalled()
|
|
expect(error).not.toHaveBeenCalled()
|
|
})
|
|
|
|
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, 'SECRET=1\n')
|
|
})
|
|
|
|
await createWorktreeLinkedPaths(primary, worktree, ['.env'], {
|
|
platform: 'darwin',
|
|
cloneWorktreePath
|
|
})
|
|
|
|
expect(cloneWorktreePath).toHaveBeenCalledWith(
|
|
join(primary, '.env'),
|
|
join(worktree, '.env'),
|
|
false
|
|
)
|
|
expect(lstatSync(join(worktree, '.env')).isSymbolicLink()).toBe(false)
|
|
expect(statSync(join(worktree, '.env')).isFile()).toBe(true)
|
|
})
|
|
|
|
it('does not overwrite a file target that appears before APFS clone-copy is published', async () => {
|
|
writeFileSync(join(primary, '.env'), 'SECRET=1\n')
|
|
const target = join(worktree, '.env')
|
|
const deps = createApfsCloneDeps({
|
|
uuid: 'file-race',
|
|
onCp: (args) => {
|
|
const tempTarget = args.at(-1)
|
|
if (!tempTarget) {
|
|
throw new Error('Missing APFS clone temp target')
|
|
}
|
|
writeFileSync(tempTarget, 'SECRET=1\n')
|
|
writeFileSync(target, 'RACE=1\n')
|
|
}
|
|
})
|
|
|
|
await createWorktreeLinkedPaths(primary, worktree, ['.env'], {
|
|
platform: 'darwin',
|
|
apfsCloneDeps: deps
|
|
})
|
|
|
|
expect(readFileSync(target, 'utf8')).toBe('RACE=1\n')
|
|
expect(existsSync(join(worktree, '.orca-apfs-clone-file-race'))).toBe(false)
|
|
expect(warn).not.toHaveBeenCalled()
|
|
expect(error).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('does not replace a directory target that appears before APFS clone-copy reserves it', async () => {
|
|
mkdirSync(join(primary, 'node_modules'))
|
|
writeFileSync(join(primary, 'node_modules', 'primary-marker'), 'PRIMARY\n')
|
|
const target = join(worktree, 'node_modules')
|
|
let createdRacedTarget = false
|
|
const deps = createApfsCloneDeps({
|
|
onDiskutil: () => {
|
|
if (!createdRacedTarget) {
|
|
createdRacedTarget = true
|
|
mkdirSync(target)
|
|
writeFileSync(join(target, 'user-marker'), 'USER\n')
|
|
}
|
|
},
|
|
onCp: () => {
|
|
throw new Error('APFS clone-copy should not run after the target appears')
|
|
}
|
|
})
|
|
|
|
await createWorktreeLinkedPaths(primary, worktree, ['node_modules'], {
|
|
platform: 'darwin',
|
|
apfsCloneDeps: deps
|
|
})
|
|
|
|
expect(readFileSync(join(target, 'user-marker'), 'utf8')).toBe('USER\n')
|
|
expect(existsSync(join(target, 'primary-marker'))).toBe(false)
|
|
expect(deps.execFileAsync).not.toHaveBeenCalledWith('/bin/cp', expect.anything())
|
|
expect(warn).not.toHaveBeenCalled()
|
|
expect(error).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('does not overwrite or remove a nested target after APFS clone-copy hits a conflict', async () => {
|
|
const source = join(primary, 'node_modules')
|
|
mkdirSync(source)
|
|
writeFileSync(join(primary, 'node_modules', 'primary-marker'), 'PRIMARY\n')
|
|
const target = join(worktree, 'node_modules')
|
|
let cpArgs: readonly string[] | undefined
|
|
const deps = createApfsCloneDeps({
|
|
onCp: (args) => {
|
|
cpArgs = args
|
|
writeFileSync(join(target, 'primary-marker'), 'USER\n')
|
|
throw new Error('clone-copy skipped an existing nested target')
|
|
}
|
|
})
|
|
|
|
await createWorktreeLinkedPaths(primary, worktree, ['node_modules'], {
|
|
platform: 'darwin',
|
|
apfsCloneDeps: deps
|
|
})
|
|
|
|
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'),
|
|
expect.any(Error)
|
|
)
|
|
expect(error).toHaveBeenCalledWith(
|
|
expect.stringContaining('[worktree-symlinks] Failed to link "node_modules"'),
|
|
expect.any(Error)
|
|
)
|
|
})
|
|
|
|
posixIt('preserves the source directory mode after APFS clone-copy reserves it', async () => {
|
|
const source = join(primary, 'node_modules')
|
|
mkdirSync(source)
|
|
chmodSync(source, 0o700)
|
|
const target = join(worktree, 'node_modules')
|
|
const deps = createApfsCloneDeps({
|
|
onCp: () => {
|
|
writeFileSync(join(target, 'marker'), 'CLONED\n')
|
|
}
|
|
})
|
|
|
|
await createWorktreeLinkedPaths(primary, worktree, ['node_modules'], {
|
|
platform: 'darwin',
|
|
apfsCloneDeps: deps
|
|
})
|
|
|
|
expect(statSync(target).mode & 0o777).toBe(0o700)
|
|
expect(readFileSync(join(target, 'marker'), 'utf8')).toBe('CLONED\n')
|
|
})
|
|
|
|
it('falls back to 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 createWorktreeLinkedPaths(primary, worktree, ['.env'], {
|
|
platform: 'darwin',
|
|
cloneWorktreePath
|
|
})
|
|
|
|
expect(lstatSync(join(worktree, '.env')).isSymbolicLink()).toBe(true)
|
|
expect(readlinkSync(join(worktree, '.env'))).toBe(join(primary, '.env'))
|
|
expect(warn).toHaveBeenCalledWith(
|
|
expect.stringContaining('[worktree-symlinks] APFS clone-copy unavailable'),
|
|
expect.any(Error)
|
|
)
|
|
})
|
|
|
|
it('does not delete a target that appears while APFS clone-copy is failing', async () => {
|
|
writeFileSync(join(primary, '.env'), 'SECRET=1\n')
|
|
const cloneWorktreePath = vi.fn(async (_source: string, target: string) => {
|
|
writeFileSync(target, 'RACE=1\n')
|
|
throw new Error('clonefile failed after target appeared')
|
|
})
|
|
|
|
await createWorktreeLinkedPaths(primary, worktree, ['.env'], {
|
|
platform: 'darwin',
|
|
cloneWorktreePath
|
|
})
|
|
|
|
expect(readFileSync(join(worktree, '.env'), 'utf8')).toBe('RACE=1\n')
|
|
expect(error).toHaveBeenCalledWith(
|
|
expect.stringContaining('[worktree-symlinks] Failed to link ".env"'),
|
|
expect.any(Error)
|
|
)
|
|
})
|
|
|
|
it('keeps symlink sources as symlinks instead of APFS clone-copying their targets', async () => {
|
|
writeFileSync(join(primary, '.env.real'), 'SECRET=1\n')
|
|
symlinkSync(join(primary, '.env.real'), join(primary, '.env'), 'file')
|
|
const cloneWorktreePath = vi.fn(async () => {
|
|
throw new Error('clone should not be called for symlink sources')
|
|
})
|
|
|
|
await createWorktreeLinkedPaths(primary, worktree, ['.env'], {
|
|
platform: 'darwin',
|
|
cloneWorktreePath
|
|
})
|
|
|
|
expect(cloneWorktreePath).not.toHaveBeenCalled()
|
|
expect(lstatSync(join(worktree, '.env')).isSymbolicLink()).toBe(true)
|
|
expect(readlinkSync(join(worktree, '.env'))).toBe(join(primary, '.env'))
|
|
})
|
|
})
|
|
|
|
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')
|
|
})
|
|
|
|
// 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
|
|
let worktree: string
|
|
let error: ReturnType<typeof vi.spyOn>
|
|
|
|
beforeEach(() => {
|
|
root = mkdtempSync(join(tmpdir(), 'orca-unlink-'))
|
|
primary = join(root, 'primary')
|
|
worktree = join(root, 'worktree')
|
|
mkdirSync(primary, { recursive: true })
|
|
mkdirSync(worktree, { recursive: true })
|
|
error = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
})
|
|
|
|
afterEach(() => {
|
|
error.mockRestore()
|
|
rmSync(root, { recursive: true, force: true })
|
|
})
|
|
|
|
it('unlinks configured symlinks from the worktree', async () => {
|
|
writeFileSync(join(primary, '.env'), 'SECRET=1\n')
|
|
mkdirSync(join(primary, 'node_modules'))
|
|
symlinkSync(join(primary, '.env'), join(worktree, '.env'), 'file')
|
|
symlinkSync(join(primary, 'node_modules'), join(worktree, 'node_modules'), 'dir')
|
|
|
|
await removeWorktreeSymlinks(worktree, ['.env', 'node_modules'])
|
|
|
|
expect(existsSync(join(worktree, '.env'))).toBe(false)
|
|
expect(existsSync(join(worktree, 'node_modules'))).toBe(false)
|
|
// Source is untouched.
|
|
expect(statSync(join(primary, '.env')).isFile()).toBe(true)
|
|
expect(statSync(join(primary, 'node_modules')).isDirectory()).toBe(true)
|
|
})
|
|
|
|
it('leaves a regular file at the configured path alone', async () => {
|
|
// Why: a user who created a real file at `.env` (instead of symlinking)
|
|
// must not lose it just because `.env` is in the configured list.
|
|
writeFileSync(join(worktree, '.env'), 'USER_WROTE_THIS=1\n')
|
|
|
|
await removeWorktreeSymlinks(worktree, ['.env'])
|
|
|
|
expect(lstatSync(join(worktree, '.env')).isSymbolicLink()).toBe(false)
|
|
expect(statSync(join(worktree, '.env')).isFile()).toBe(true)
|
|
})
|
|
|
|
it('identifies only actual symlinks for removal preflight', async () => {
|
|
writeFileSync(join(primary, '.env'), 'PRIMARY=1\n')
|
|
symlinkSync(join(primary, '.env'), join(worktree, '.env'))
|
|
writeFileSync(join(worktree, 'config.json'), '{}\n')
|
|
|
|
await expect(
|
|
findExistingWorktreeSymlinkPaths(worktree, ['.env', 'config.json', 'missing'])
|
|
).resolves.toEqual(['.env'])
|
|
})
|
|
|
|
it('leaves APFS clone-copied regular files for git removal to judge', async () => {
|
|
writeFileSync(join(worktree, '.env'), 'CLONED=1\n')
|
|
|
|
await removeWorktreeLinkedPaths(worktree, ['.env'])
|
|
|
|
expect(existsSync(join(worktree, '.env'))).toBe(true)
|
|
})
|
|
|
|
it('ignores missing entries', async () => {
|
|
await removeWorktreeSymlinks(worktree, ['.env', 'node_modules'])
|
|
expect(error).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('rejects unsafe paths without touching the filesystem', async () => {
|
|
// Parent-dir traversal is silently skipped; no unlink attempted.
|
|
writeFileSync(join(root, 'outside-file'), 'DO_NOT_DELETE')
|
|
await removeWorktreeSymlinks(worktree, ['../outside-file'])
|
|
expect(existsSync(join(root, 'outside-file'))).toBe(true)
|
|
})
|
|
|
|
it('is a no-op for an empty paths list', async () => {
|
|
await removeWorktreeSymlinks(worktree, [])
|
|
expect(error).not.toHaveBeenCalled()
|
|
})
|
|
})
|