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