( repoRoot: string, worktreePath: string, )
| 389 | * that directory is expanded with a second scoped `ls-files` call. |
| 390 | */ |
| 391 | export async function copyWorktreeIncludeFiles( |
| 392 | repoRoot: string, |
| 393 | worktreePath: string, |
| 394 | ): Promise<string[]> { |
| 395 | let includeContent: string |
| 396 | try { |
| 397 | includeContent = await readFile(join(repoRoot, '.worktreeinclude'), 'utf-8') |
| 398 | } catch { |
| 399 | return [] |
| 400 | } |
| 401 | |
| 402 | const patterns = includeContent |
| 403 | .split(/\r?\n/) |
| 404 | .map(line => line.trim()) |
| 405 | .filter(line => line.length > 0 && !line.startsWith('#')) |
| 406 | if (patterns.length === 0) { |
| 407 | return [] |
| 408 | } |
| 409 | |
| 410 | // Single pass with --directory: collapses fully-gitignored dirs (node_modules/, |
| 411 | // .turbo/, etc.) into single entries instead of listing every file inside. |
| 412 | // In a large repo this cuts ~500k entries/~7s down to ~hundreds of entries/~100ms. |
| 413 | const gitignored = await execFileNoThrowWithCwd( |
| 414 | gitExe(), |
| 415 | ['ls-files', '--others', '--ignored', '--exclude-standard', '--directory'], |
| 416 | { cwd: repoRoot }, |
| 417 | ) |
| 418 | if (gitignored.code !== 0 || !gitignored.stdout.trim()) { |
| 419 | return [] |
| 420 | } |
| 421 | |
| 422 | const entries = gitignored.stdout.trim().split('\n').filter(Boolean) |
| 423 | const matcher = ignore().add(includeContent) |
| 424 | |
| 425 | // --directory emits collapsed dirs with a trailing slash; everything else is |
| 426 | // an individual file. |
| 427 | const collapsedDirs = entries.filter(e => e.endsWith('/')) |
| 428 | const files = entries.filter(e => !e.endsWith('/') && matcher.ignores(e)) |
| 429 | |
| 430 | // Edge case: a .worktreeinclude pattern targets a path inside a collapsed dir |
| 431 | // (e.g. pattern `config/secrets/api.key` when all of `config/secrets/` is |
| 432 | // gitignored with no tracked siblings). Expand only dirs where a pattern has |
| 433 | // that dir as its explicit path prefix (stripping redundant leading `/`), the |
| 434 | // dir falls under an anchored glob's literal prefix (e.g. `config/**/*.key` |
| 435 | // expands `config/secrets/`), or the dir itself matches a pattern. We don't |
| 436 | // expand for `**/` or anchorless patterns -- those match files in tracked dirs |
| 437 | // (already listed individually) and expanding every collapsed dir for them |
| 438 | // would defeat the perf win. |
| 439 | const dirsToExpand = collapsedDirs.filter(dir => { |
| 440 | if ( |
| 441 | patterns.some(p => { |
| 442 | const normalized = p.startsWith('/') ? p.slice(1) : p |
| 443 | // Literal prefix match: pattern starts with the collapsed dir path |
| 444 | if (normalized.startsWith(dir)) return true |
| 445 | // Anchored glob: dir falls under the pattern's literal (non-glob) prefix |
| 446 | // e.g. `config/**/*.key` has literal prefix `config/` → expand `config/secrets/` |
| 447 | const globIdx = normalized.search(/[*?[]/) |
| 448 | if (globIdx > 0) { |
no test coverage detected