Files
orca/src/main/git/worktree-include-file.ts
T
Brennan Benson b600e25fa1 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.
2026-07-24 15:31:23 -07:00

135 lines
5.0 KiB
TypeScript

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 []
}
}