/** * Directory walking with git semantics, shared by pack/sync (dependency-layout resolution) and * install (rename adoption): each directory's `.gitignore` governs its own subtree, an ignored * directory is pruned without entering it, and `.git`/`node_modules` never count. */ import * as fs from 'fs/promises' import * as path from 'path' import ignore from 'ignore' export const GITIGNORE_FILENAME = '.gitignore' // Directories that never belong in a published asset — VCS metadata and reinstallable // dependencies. Excluded structurally in every walk, regardless of any `.gitignore`. export const ALWAYS_IGNORED_DIRS = new Set(['.git', 'node_modules']) export interface GitignoreMatcher { /** Directory the `.gitignore` governs: '' for the root, or e.g. 'sub/'. */ dir: string filter: ReturnType } export function isGitignored(name: string, matchers: GitignoreMatcher[]): boolean { // Directory candidates carry a trailing slash; the `ignore` lib matches directory patterns // (`dist/`) against exactly that form, so the path is passed through unchanged. return matchers.some((matcher) => { if (!name.startsWith(matcher.dir)) return false const relative = name.slice(matcher.dir.length) return relative.length > 0 && matcher.filter.ignores(relative) }) } /** Walk a directory tree; returns kept files as posix paths relative to the root, plus the count * of ignored entries. */ export async function walkWithGitignore( root: string, ): Promise<{ kept: string[]; ignored: number }> { const kept: string[] = [] let ignored = 0 async function descend(dir: string, matchers: GitignoreMatcher[]): Promise { const scoped = [...matchers] const gitignore = await maybeStat(path.join(dir, GITIGNORE_FILENAME)) if (gitignore?.isFile()) { const prefix = toPosix(path.relative(root, dir)) scoped.push({ dir: prefix ? `${prefix}/` : '', filter: ignore().add(await fs.readFile(path.join(dir, GITIGNORE_FILENAME), 'utf8')), }) } const entries = await fs.readdir(dir, { withFileTypes: true }) for (const entry of entries) { if (ALWAYS_IGNORED_DIRS.has(entry.name)) continue const relative = toPosix(path.relative(root, path.join(dir, entry.name))) const candidate = entry.isDirectory() ? `${relative}/` : relative if (isGitignored(candidate, scoped)) { ignored += 1 continue } if (entry.isDirectory()) await descend(path.join(dir, entry.name), scoped) else if (entry.isFile()) kept.push(relative) } } await descend(root, []) return { kept, ignored } } /** The nearest ancestor (including start) holding a package.json — the project root. */ export async function findInstallRoot(cwd: string = process.cwd()): Promise { const start = path.resolve(cwd) for (let dir = start; ; dir = path.dirname(dir)) { if ((await maybeStat(path.join(dir, 'package.json')))?.isFile()) { return dir } if (path.dirname(dir) === dir) { return start } } } function toPosix(p: string): string { return p.split(path.sep).join('/') } async function maybeStat(file: string) { try { return await fs.stat(file) } catch (error) { if (error instanceof Error && 'code' in error && error.code === 'ENOENT') return null throw error } }