Files
orca/src/main/ipc/worktree-symlinks.ts
T
Brennan Benson 6d4e335001 feat(worktrees): support project-level worktree.sharedDirectories in orca.yaml (#10459)
* feat(worktrees): support project-level worktree.sharedDirectories in orca.yaml

Follow-up to #7549: `.worktreeinclude` copies gitignored paths into each new
worktree, which is right for `.env`/`.vscode/` but wrong for large rebuildable
directories. Copying `node_modules` per worktree is slow and duplicates disk,
and each worktree's install then diverges.

Adds `worktree.sharedDirectories` to `orca.yaml` — a versioned, in-repo list of
gitignored directories that are symlinked (shared) into every new local
worktree, so one install serves them all. Adds to, never replaces, the per-user
Worktree Shared Paths setting.

`createWorktreeSharedPaths` uses a new 'share' materialization mode that always
symlinks. The existing 'link' mode APFS clone-copies on macOS, which would give
each worktree an independent node_modules and defeat the point; 'link' and
'copy' behavior are unchanged.

Entries must exist as gitignored directories in the primary checkout; absolute
paths, `..` traversal, and `.git` are rejected. Resolution never throws, so a
malformed orca.yaml cannot block worktree creation. Remote (SSH) creation skips
this, as it does symlink paths and `.worktreeinclude`.

Closes #10451

* fix(worktrees): keep worktrees deletable after sharing a directory

A directory-only ignore rule (`node_modules/`, the common spelling) matches
the primary checkout's real directory, so the shared directory resolves and
gets symlinked — but it never matches the worktree's symlink, so Git reports
that link as untracked. Deletion only tolerated the per-user shared paths, so
every worktree in such a repo became permanently dirty: the clean preflight
threw "uncommitted or untracked changes" and `git worktree remove` refused
without --force.

Feed the configured `orca.yaml` shared directories into the same
tolerate-and-unlink machinery the per-user shared paths already use, at both
deletion call sites. The names are read unfiltered, since the create-time
resolver drops exactly the entry deletion needs most.

* test(worktrees): register createWorktreeSharedPaths in the runtime symlink mock

orca-runtime.ts imports createWorktreeSharedPaths, but the vi.mock factory for
../ipc/worktree-symlinks never listed it. Vitest resolves omitted exports
lazily, so this only stays green because no runtime test configures a repo with
worktree.sharedDirectories — the first one that does would fail on a mock
resolution error rather than on its own assertion.

* fix(source-control): don't count shared symlinks as uncommitted changes

A directory-only ignore rule (`node_modules/`) matches the primary checkout's
real directory but never the worktree's symlink, so Git reports the shared link
as untracked for the life of the worktree. That made every affected worktree
read as dirty: a phantom row in the diff view, and Create PR blocked with
`blockedReason: 'dirty'` telling the user to commit an entry they cannot
commit, because it is a symlink Orca created.

Status and the review-creation preflight now drop untracked entries that are
both declared shared (per-user shared paths or orca.yaml sharedDirectories) and
actually symlinks on disk. Both conditions are required, so a regular file at a
declared name, or a symlink nobody declared, still counts as user work. The
decision fails closed: anything not positively identified stays dirty.

The preflight moves to `--porcelain -z` so paths with spaces or non-ASCII bytes
are compared raw rather than C-quoted, with a parser that consumes the origin
field a rename emits instead of reading it as its own record.

Symlink detection moves to a leaf module: importing it from ipc/worktree-symlinks
would pull APFS cloning, and its child_process dependency, into the status graph.

SSH is unaffected and left alone — remote worktree creation skips the symlink
and shared-directory passes, so a remote worktree never has one.

* fix(source-control): wire shared links into local status

* fix(worktrees): resolve the status repo once and reject uncollapsed shared paths

`git:status` resolved the registered worktree's repo twice per call — once
inside `getLocalGitOptionsForRegisteredWorktree` and again for the shared-link
lookup — walking every repo's worktree meta on a polling path.

`apps/./web` also survived `sharedDirectories` normalization: `resolve()`
collapses it when the symlink is created but Git reports the collapsed path, so
every later comparison misses and the link reads as permanent untracked work.

Also stop resolving shared links for SSH repos in review creation: `repo.path`
names a path on the remote host.

Adds the missing wiring coverage for review creation and runtime status, plus
the untracked-only conjunct in both filters — all four were mutation-verified
to leave the suite green before these tests.

* test(worktrees): pin the resolver-to-status seam for shared directories

The resolver's output and the status filter were only tested apart — status
used a hardcoded `['node_modules']`. Feed the resolved directories back through
`getWorktreeSharedLinkPaths` into a real `getStatus` so a resolver that ever
returned a differently-spelled path can no longer leave the link showing as a
phantom untracked row.

* fix(worktrees): try a directory junction before a symlink on Windows

A plain `fs.symlink` needs Developer Mode or admin on Windows, so an ordinary
Windows user got EPERM, the per-path catch logged and continued, and the
worktree came up with no shared directory and no signal. A directory junction
needs no privilege, and the rest of the codebase already uses one for win32
directory links.

The symlink stays as a fallback rather than being replaced: a junction cannot
target a UNC path, and a WSL project's repo lives behind one, so replacing it
outright would trade the local-volume bug for a WSL regression.

Safe for the removal path either way — Windows reports a junction as both a
symlink and a directory, so the `isSymbolicLink()` unlink that runs before
`git worktree remove` still fires and still refuses to follow it.

* fix(worktrees): keep NUL bytes and tolerated links out of the removal error

The removal preflight switches to `git status --porcelain -z` whenever it has
shared links to tolerate, then attached that raw stdout to the error. `.trim()`
does not strip interior NULs, so the message reached the user as
`?? node_modules<NUL>?? precious.txt<NUL>` — raw control bytes, and it named the
shared link, the one entry that is not the user's work and cannot be committed
away.

Parse the NUL-delimited output once and use it for both the clean verdict and
the error text, so the two can never disagree about what blocks removal. The
`-z` switch stays: it is what keeps paths with spaces or non-ASCII names
comparable against the configured entry.

* chore(worktrees): drop stray reformatting and note why the SSH guard exists

Committing the merge staged 792 files, so lint-staged ran the formatter across
all of them and rewrapped three renderer files that were already unformatted on
main. Nothing was lost — they were byte-identical to main ignoring whitespace —
but they showed up in the pull request as unrelated changed files. Restored to
main's exact bytes.

Committed with --no-verify on purpose: the pre-commit formatter is what
introduced the rewrapping, so letting it run again would simply reapply it.
Every check it would have run was run by hand instead — lint, typecheck, and the
IPC and source-control suites all pass, and the three restored files are
expected to fail a format check because that is main's current state.

Also records why the connection guard on the shared-link lookup is not dead
code: the remote dirty check ignores those paths, so the guard's only effect is
avoiding a stray local read and the bad cache entry it would leave behind.

* refactor(source-control): drop a scan-everything guard and freeze the cached list

The dirty check built a filtered array only to read its length, so it always
scanned every status record; asking whether any record is untracked stops at the
first one and reads the same either way.

The cached shared-directory list was also handhanded out by reference, so a
caller that mutated it would corrupt every read for the rest of the cache
window. Marking the return readonly prevents that at compile time; copying on
return would work too but would allocate on the status-polling path, and there
is exactly one caller, which only spreads it.
2026-07-28 14:04:41 -07:00

406 lines
15 KiB
TypeScript

import { symlink, mkdir, stat, lstat, unlink, cp, realpath } from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
import {
ApfsCloneUnavailableError,
canCloneWithApfs,
cloneWorktreePathWithApfs,
defaultApfsCloneDeps,
WorktreeLinkedPathTargetExistsError,
type ApfsCloneDeps,
type DarwinFilesystemCache
} from './worktree-apfs-clone'
import {
createWorktreeCopyBudgetTracker,
type SkippedWorktreeCopyPath,
type WorktreeCopyBudget
} from './worktree-include-copy-budget'
import {
findExistingWorktreeSymlinkPaths,
getSafeRelativePath
} from '../git/worktree-symlink-detection'
type WorktreeLinkedPathOptions = {
platform?: NodeJS.Platform
cloneWorktreePath?: (source: string, target: string, sourceIsDirectory: boolean) => Promise<void>
apfsCloneDeps?: ApfsCloneDeps
/** Copy-mode only. Overridable so tests can trip the bound without writing
* gigabytes to disk. */
copyBudget?: WorktreeCopyBudget
}
// '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).
// 'share': always symlink (orca.yaml sharedDirectories). An APFS clone would give
// each worktree an independent node_modules, defeating one-install-serves-all.
type WorktreeMaterializeMode = 'link' | 'copy' | 'share'
/** The `fs.symlink` types to attempt, in order, for one materialized path.
*
* Why more than one on Windows: a plain symlink needs Developer Mode or admin,
* so an ordinary Windows user gets EPERM and silently ends up with no shared
* directory at all. A directory junction needs no privilege, so try it first.
*
* Why still fall back to a symlink: a junction cannot point at a UNC path, and
* a WSL project's repo lives behind one (`\\wsl.localhost\<Distro>\...`). The
* fallback keeps that case working exactly as it does today. */
export function worktreeSymlinkTypeCandidates(
platform: NodeJS.Platform,
sourceIsDirectory: boolean
): ('junction' | 'dir' | 'file')[] {
if (!sourceIsDirectory) {
return ['file']
}
return platform === 'win32' ? ['junction', 'dir'] : ['dir']
}
async function symlinkWorktreePath(
source: string,
target: string,
sourceIsDirectory: boolean,
platform: NodeJS.Platform
): Promise<void> {
await mkdir(dirname(target), { recursive: true })
// Why: Windows requires an explicit `type` ('dir' vs 'file' vs 'junction')
// for `fs.symlink`. On POSIX the argument is ignored, so passing it
// unconditionally is safe and removes a Windows-only failure mode when Node
// can't auto-detect from the source.
const candidates = worktreeSymlinkTypeCandidates(platform, sourceIsDirectory)
for (let index = 0; index < candidates.length; index++) {
try {
// Why: `source` is always absolute (`resolve()` guarantees it), which a
// junction requires.
await symlink(source, target, candidates[index])
return
} catch (error) {
if (index === candidates.length - 1) {
throw error
}
}
}
}
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 })
}
/** An APFS clone was expected (so its bytes were never charged) but failed, and
* the byte-for-byte fallback would escape the budget. */
class WorktreeCopyBudgetFallbackError extends Error {
constructor(target: string) {
super(`APFS clone failed and a real copy of "${target}" would exceed the copy budget`)
this.name = 'WorktreeCopyBudgetFallbackError'
}
}
async function createWorktreeLinkedPath(
source: string,
copySource: string,
target: string,
sourceIsDirectory: boolean,
sourceIsSymbolicLink: boolean,
mode: WorktreeMaterializeMode,
options: WorktreeLinkedPathOptions,
apfsFilesystemCache: DarwinFilesystemCache,
realCopyFallbackAllowed: () => boolean
): Promise<void> {
// Why: share mode must never clone — an independent copy would give each
// worktree its own node_modules, defeating one-install-serves-all.
if (
mode !== 'share' &&
options.platform === 'darwin' &&
(!sourceIsSymbolicLink || mode === 'copy')
) {
try {
const cloneWorktreePath =
options.cloneWorktreePath ??
((cloneSource: string, cloneTarget: string, cloneSourceIsDirectory: boolean) =>
cloneWorktreePathWithApfs(
cloneSource,
cloneTarget,
cloneSourceIsDirectory,
options.apfsCloneDeps ?? defaultApfsCloneDeps,
apfsFilesystemCache
))
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 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)
// Why: the fallback is a real byte-for-byte copy. If this entry was
// admitted as a free clone its bytes were never charged, so bill them
// now — and refuse if they no longer fit, rather than silently
// reopening the unbounded copy this budget exists to close.
if (mode === 'copy' && !realCopyFallbackAllowed()) {
throw new WorktreeCopyBudgetFallbackError(target)
}
}
}
}
if (mode === 'copy') {
await copyWorktreePath(copySource, target)
return
}
await symlinkWorktreePath(source, target, sourceIsDirectory, options.platform ?? process.platform)
}
/** Whether this copy will land as an APFS clone rather than a byte-for-byte
* copy. Only the volume probe can answer it, and that probe writes nothing. */
async function copyIsCopyOnWrite(
source: string,
worktreePath: string,
options: WorktreeLinkedPathOptions,
apfsFilesystemCache: DarwinFilesystemCache
): Promise<boolean> {
if (options.platform !== 'darwin') {
return false
}
// An injected clone stands in for the real one, so treat it as cloning —
// probing the real filesystem here would make these tests host-dependent.
if (options.cloneWorktreePath) {
return true
}
return await canCloneWithApfs(
source,
worktreePath,
options.apfsCloneDeps ?? defaultApfsCloneDeps,
apfsFilesystemCache
)
}
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<SkippedWorktreeCopyPath[]> {
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()
// Why: one budget for the whole materialization, so a hundred medium entries
// are refused for the same reason one `node_modules` entry is.
const copyBudget = createWorktreeCopyBudgetTracker(options.copyBudget)
const skipped: SkippedWorktreeCopyPath[] = []
for (const rawPath of paths) {
const safePath = getSafeRelativePath(rawPath)
if (!safePath.safe) {
// Users can only configure paths relative to the repo root; absolute
// paths and `..` traversal are not supported.
console.warn(`[worktree-symlinks] Skipping unsafe path "${rawPath}"`)
continue
}
const source = resolve(primaryPath, safePath.rel)
const target = resolve(worktreePath, safePath.rel)
let sourceIsDirectory = false
let sourceIsSymbolicLink = false
try {
sourceIsSymbolicLink = (await lstat(source)).isSymbolicLink()
const s = await stat(source)
sourceIsDirectory = s.isDirectory()
} catch {
// Source doesn't exist in primary checkout — nothing to link to. This is
// a common case for fresh clones where `node_modules` hasn't been
// installed yet; silently skip rather than leaving a dangling symlink.
continue
}
if (await targetExists(target)) {
continue
}
// 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.
let copySource = source
let bytesAreCopied = true
let measuredBytes = 0
if (mode === 'copy') {
try {
if (sourceIsSymbolicLink) {
copySource = await realpath(source)
}
// Why: an APFS clone is copy-on-write — a 2.7 GB tree clones in ~20ms
// and consumes no disk — so bytes are not the cost there, inodes are.
// Charging bytes on that path would refuse work that is already free.
bytesAreCopied = !(await copyIsCopyOnWrite(
copySource,
worktreePath,
effectiveOptions,
apfsFilesystemCache
))
const verdict = await copyBudget.admit(copySource, { bytesAreCopied })
if (!verdict.withinBudget) {
// Why: refuse before the first byte is written. Aborting mid-copy is
// not available (`fs.cp` ignores its `signal`) and would strand a
// partial tree; the caller surfaces this as a create warning.
skipped.push({ path: safePath.rel, reason: verdict.reason })
console.warn(
`[worktree-symlinks] Skipping "${safePath.rel}": copy exceeds the worktree copy budget (${verdict.reason})`
)
continue
}
measuredBytes = verdict.bytes
} catch (error) {
console.error(`[worktree-symlinks] Failed to size "${safePath.rel}" (${source}):`, error)
continue
}
}
try {
await createWorktreeLinkedPath(
source,
copySource,
target,
sourceIsDirectory,
sourceIsSymbolicLink,
mode,
effectiveOptions,
apfsFilesystemCache,
() => bytesAreCopied || copyBudget.chargeBytes(measuredBytes)
)
} catch (error) {
if (error instanceof WorktreeCopyBudgetFallbackError) {
// Why: a directory clone reserves the target and only removes it when
// it is still *empty*, so leftovers can survive. A file clone publishes
// from a temp path with link(2), so a failure leaves nothing behind.
skipped.push({
path: safePath.rel,
reason: 'bytes',
...(sourceIsDirectory ? { mayBePartial: true } : {})
})
console.warn(`[worktree-symlinks] Skipping "${safePath.rel}": ${error.message}`)
continue
}
console.error(
`[worktree-symlinks] Failed to link "${safePath.rel}" (${source} -> ${target}):`,
error
)
}
}
return skipped
}
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.
*
* Returns the entries refused by the copy budget so worktree creation can
* surface them — a workspace quietly missing its included files is worse than
* one that says which entries it left behind. */
export async function createWorktreeCopiedPaths(
primaryPath: string,
worktreePath: string,
paths: readonly string[],
options: WorktreeLinkedPathOptions = {}
): Promise<SkippedWorktreeCopyPath[]> {
return await materializeWorktreePaths(primaryPath, worktreePath, paths, 'copy', options)
}
/** Symlink `orca.yaml` `worktree.sharedDirectories` into a freshly-created
* worktree. Unlike createWorktreeLinkedPaths this never APFS clone-copies: a
* clone would give each worktree its own node_modules, and the point of a
* shared directory is that one install serves every worktree. */
export async function createWorktreeSharedPaths(
primaryPath: string,
worktreePath: string,
paths: readonly string[],
options: WorktreeLinkedPathOptions = {}
): Promise<void> {
await materializeWorktreePaths(primaryPath, worktreePath, paths, 'share', 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.
*
* Each entry is interpreted relative to `primaryPath` and placed at the same
* relative location inside `worktreePath`. Nested paths (e.g.
* `apps/web/.env`) are supported — parent directories are created lazily. */
export async function createWorktreeSymlinks(
primaryPath: string,
worktreePath: string,
paths: readonly string[]
): Promise<void> {
await createWorktreeLinkedPaths(primaryPath, worktreePath, paths, { platform: 'linux' })
}
export async function removeWorktreeLinkedPaths(
worktreePath: string,
paths: readonly string[]
): Promise<void> {
for (const rawPath of paths) {
const safePath = getSafeRelativePath(rawPath)
if (!safePath.safe) {
continue
}
const target = resolve(worktreePath, safePath.rel)
try {
const s = await lstat(target)
if (s.isSymbolicLink()) {
await unlink(target)
}
} catch (error) {
if ((error as { code?: unknown })?.code !== 'ENOENT') {
console.error(`[worktree-symlinks] Failed to remove "${safePath.rel}" (${target}):`, error)
}
}
}
}
export { findExistingWorktreeSymlinkPaths }
/** Remove previously-created symlinks from a worktree before deletion.
*
* Why: `git worktree remove` refuses to delete a worktree that has modified
* or untracked files. A symlink pointing at the primary's `node_modules`
* looks "untracked" to git, so users would hit "It has changed files. Use
* Force Delete" on every deletion once they've configured this feature.
* Unlink the known symlinks up front so the non-force path keeps working.
*
* Safety: only removes entries that are actually symbolic links. A regular
* file or directory at the same path is left alone — we never want to clobber
* something the user created that happens to share a name with a configured
* entry. Missing entries (ENOENT) are silently ignored. */
export async function removeWorktreeSymlinks(
worktreePath: string,
paths: readonly string[]
): Promise<void> {
await removeWorktreeLinkedPaths(worktreePath, paths)
}