Files
orca/src/main/git/worktree-include-file.ts
T

147 lines
5.6 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'
import { mapWithConcurrency } from '../../shared/map-with-concurrency'
/** 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
// Why: include files can name hundreds of independent paths; bound the local
// stat fan-out while avoiding one serial filesystem round trip per entry.
const WORKTREE_INCLUDE_PATH_STAT_CONCURRENCY = 8
/** 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. The mapper
// retains candidate order for deterministic git-ignore input and output.
const existence = await mapWithConcurrency(
candidates,
WORKTREE_INCLUDE_PATH_STAT_CONCURRENCY,
async (relativePath) => {
try {
await lstat(join(repoPath, relativePath))
return relativePath
} catch {
// Absent in the primary checkout — nothing to copy.
return null
}
}
)
const existing = existence.filter(
(relativePath): relativePath is string => relativePath !== null
)
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 []
}
}