mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 16:02:32 +00:00
147 lines
5.6 KiB
TypeScript
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 []
|
|
}
|
|
}
|