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