diff --git a/gitnexus/src/config/ignore-service.ts b/gitnexus/src/config/ignore-service.ts index ac2dc7704..6af0e479a 100644 --- a/gitnexus/src/config/ignore-service.ts +++ b/gitnexus/src/config/ignore-service.ts @@ -1,5 +1,13 @@ import ignore, { type Ignore } from 'ignore'; -import { existsSync } from 'fs'; +import { + closeSync, + constants as fsConstants, + existsSync, + fstatSync, + lstatSync, + openSync, + readFileSync, +} from 'fs'; import fs from 'fs/promises'; import nodePath from 'path'; import type { Path } from 'path-scurry'; @@ -531,9 +539,163 @@ const hasExplicitUnignore = (ig: Ignore, rel: string): boolean => { return false; }; +/** + * Read a nested `.gitignore` only if it is a regular file, not a symlink. + * + * git does not follow a symlinked `.gitignore` in the working tree, and + * reading one could pull rules from outside the repository. Where the + * platform supports it, the file is opened with O_NOFOLLOW (a symlink fails + * with ELOOP). Windows has no O_NOFOLLOW, so there the path is lstat'ed after + * opening and must be the same regular file as the open descriptor. Either + * way the content is read through the descriptor that was checked, never by + * path, so the file cannot be swapped between the check and the read. + * + * The open also passes O_NONBLOCK where it exists. Opening a FIFO for reading + * blocks in open(2) until a writer appears, so a `.gitignore` that is a FIFO + * would hang the scan before the isFile() check could reject it (glob's + * ignore callback is synchronous). The flag makes that open return at once + * and changes nothing for a regular file. Same reasoning as readBoundedFile + * in src/core/ingestion/asyncapi/document.ts. + */ +const readNestedGitignore = (filePath: string): string | null => { + const noFollow = fsConstants.O_NOFOLLOW; + const nonBlock = fsConstants.O_NONBLOCK; + const fd = openSync(filePath, fsConstants.O_RDONLY | (noFollow ?? 0) | (nonBlock ?? 0)); + try { + const stat = fstatSync(fd); + if (!stat.isFile()) return null; + if (noFollow === undefined) { + const link = lstatSync(filePath); + if (!link.isFile() || link.ino !== stat.ino || link.dev !== stat.dev) return null; + } + return readFileSync(fd, 'utf-8'); + } finally { + closeSync(fd); + } +}; + +/** + * Resolve `.gitignore` files below the repository root (#2675). + * + * `loadIgnoreRules` only reads the root `.gitignore`, so a monorepo package + * or checked-out submodule with its own `.gitignore` had its generated + * output indexed anyway. Each nested file is read lazily (glob's filter is + * synchronous) and cached per directory, and its patterns are matched + * against the path relative to that directory, like git does. + * + * Returns the effective decision when nested rules affect the path or an + * ancestor, and `undefined` otherwise. Root rules participate so a directory + * negation does not erase independent root exclusions for its children. + * The caller still gives `.gitnexusignore` its higher precedence. + */ +const createNestedGitignoreMatcher = ( + repoPath: string, + rootRules: Ignore | null, +): ((rel: string, isDirectory: boolean) => boolean | undefined) => { + const rulesFor = (dirRel: string): Ignore | null => { + let rules: Ignore | null = null; + const filePath = nodePath.join(repoPath, dirRel, '.gitignore'); + try { + const content = readNestedGitignore(filePath); + if (content !== null) rules = ignore().add(content); + } catch (err: unknown) { + const code = (err as NodeJS.ErrnoException).code; + if (code !== 'ENOENT' && code !== 'ENOTDIR' && code !== 'ELOOP') { + logger.warn(` Warning: could not read ${filePath}: ${(err as Error).message}`); + } + } + return rules; + }; + + interface Scope { + base: string; + rules: Ignore; + } + interface DirectoryContext { + scopes: Scope[]; + ignored: boolean; + nested: boolean; + } + + const relativeTo = (base: string, rel: string): string => + base ? rel.slice(base.length + 1) : rel; + const match = (scopes: Scope[], rel: string, isDirectory: boolean) => { + for (let i = scopes.length - 1; i >= 0; i--) { + const { base, rules } = scopes[i]; + const sub = relativeTo(base, rel); + const result = rules.test(isDirectory ? `${sub}/` : sub); + if (result.ignored || result.unignored) { + return { ignored: result.ignored, nested: base !== '' }; + } + } + return undefined; + }; + + const contexts = new Map([ + [ + '', + { + scopes: rootRules ? [{ base: '', rules: rootRules }] : [], + ignored: false, + nested: false, + }, + ], + ]); + + const contextFor = (dir: string): DirectoryContext => { + const cached = contexts.get(dir); + if (cached) return cached; + const parentDir = nodePath.posix.dirname(dir); + const parent = contextFor(parentDir === '.' ? '' : parentDir); + // A .gitignore inside an excluded directory cannot bring that directory + // back. Do not read rules below a parent that traversal would prune. + if (parent.ignored) { + contexts.set(dir, parent); + return parent; + } + + const result = match(parent.scopes, dir, true); + const context: DirectoryContext = { + scopes: parent.scopes, + ignored: result?.ignored ?? false, + nested: parent.nested || (result?.nested ?? false), + }; + if (!context.ignored) { + context.scopes = parent.scopes.map(({ base, rules }) => { + const sub = relativeTo(base, dir); + if (!rules.test(`${sub}/`).ignored) return { base, rules }; + // A deeper rule let us enter this directory. Clear only its inherited + // exclusion in the shallower layer; child rules must still be tested. + // Keep patterns in their original scope, and escape this literal path. + const literal = sub.replace(/[\\*?\[\]]/g, '\\$&'); + return { + base, + rules: ignore() + .add(rules) + .add({ pattern: `!/${literal}/` }), + }; + }); + const rules = rulesFor(dir); + if (rules) context.scopes.push({ base: dir, rules }); + } + contexts.set(dir, context); + return context; + }; + + return (rel: string, isDirectory: boolean): boolean | undefined => { + const parentDir = nodePath.posix.dirname(rel); + const parent = contextFor(parentDir === '.' ? '' : parentDir); + if (parent.ignored) return parent.nested ? true : undefined; + const result = match(parent.scopes, rel, isDirectory); + if (parent.nested || result?.nested) return result?.ignored ?? false; + return undefined; + }; +}; + /** * Create a glob-compatible ignore filter combining: * - .gitignore / .gitnexusignore patterns (via `ignore` package) + * - nested .gitignore files, scoped to their own directory (#2675) * - Hardcoded DEFAULT_IGNORE_LIST, IGNORED_EXTENSIONS, IGNORED_FILES * * Returns an IgnoreLike object for glob's `ignore` option, @@ -550,6 +712,13 @@ const hasExplicitUnignore = (ig: Ignore, rel: string): boolean => { */ export const createIgnoreFilter = async (repoPath: string, options?: IgnoreOptions) => { const ig = await loadIgnoreRules(repoPath, options); + const skipGitignore = options?.noGitignore ?? !!process.env.GITNEXUS_NO_GITIGNORE; + const nestedIgnores = skipGitignore ? null : createNestedGitignoreMatcher(repoPath, ig); + // A nested negation outranks the root .gitignore, as in git, but not the + // user's .gitnexusignore, so keep a matcher for that file on its own. + const nexusIgnore = nestedIgnores + ? await loadIgnoreRules(repoPath, { ...options, noGitignore: true, noGlobalIgnore: true }) + : null; return { ignored(p: Path): boolean { @@ -557,6 +726,23 @@ export const createIgnoreFilter = async (repoPath: string, options?: IgnoreOptio // native separators on Windows when called through glob. const rel = p.relative().replace(/\\/g, '/'); if (!rel) return false; + // Nested .gitignore files below the root (#2675). .gitnexusignore + // comes first, then the deepest nested .gitignore, which outranks the + // root .gitignore as in git. The nested matcher preserves independent + // root exclusions; a nested negation never rescues a hardcoded default. + // With no nested opinion the original order below applies unchanged. + if (nestedIgnores) { + if (nexusIgnore) { + if (hasExplicitUnignore(nexusIgnore, rel) && !ig?.ignores(rel)) return false; + if (nexusIgnore.ignores(rel)) return true; + } + const nested = nestedIgnores(rel, false); + if (nested === true) return true; + if (nested === false) { + if (ig && hasExplicitUnignore(ig, rel) && !ig.ignores(rel)) return false; + return shouldIgnorePath(rel); + } + } // User's .gitnexusignore negation takes precedence over hardcoded // rules (#771). If any ancestor or the path itself was explicitly // unignored AND no more-specific rule re-ignores this exact path, @@ -576,6 +762,22 @@ export const createIgnoreFilter = async (repoPath: string, options?: IgnoreOptio // list check below is defense-in-depth — do not remove `dot: false` // assuming this covers it. const rel = p.relative().replace(/\\/g, '/'); + // Nested .gitignore files below the root (#2675), same precedence as in + // `ignored` above. + if (nestedIgnores && rel) { + if (nexusIgnore) { + if (hasExplicitUnignore(nexusIgnore, rel) && !ig?.ignores(rel + '/')) { + return false; + } + if (nexusIgnore.ignores(rel + '/')) return true; + } + const nested = nestedIgnores(rel, true); + if (nested === true) return true; + if (nested === false) { + if (ig && hasExplicitUnignore(ig, rel) && !ig.ignores(rel + '/')) return false; + return isHardcodedIgnoredDirectoryAtPath(repoPath, nodePath.join(repoPath, rel)); + } + } // User's .gitnexusignore negation takes precedence (#771) — if the // user explicitly unignored this directory or any ancestor via a // !pattern rule, allow descent even if the directory name is in diff --git a/gitnexus/test/unit/ignore-service.test.ts b/gitnexus/test/unit/ignore-service.test.ts index 60d6e8f77..307fd5a15 100644 --- a/gitnexus/test/unit/ignore-service.test.ts +++ b/gitnexus/test/unit/ignore-service.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach, vi } import fs from 'fs/promises'; import path from 'path'; import os from 'os'; +import { execFileSync } from 'child_process'; import { shouldIgnorePath, isHardcodedIgnoredDirectory, @@ -687,6 +688,319 @@ describe('createIgnoreFilter', () => { }); }); +describe('createIgnoreFilter with nested .gitignore files (#2675)', () => { + let tmpDir: string; + let originalNoGitignore: string | undefined; + + const asPath = (rel: string) => ({ name: path.basename(rel), relative: () => rel }) as any; + + beforeEach(async () => { + originalNoGitignore = process.env.GITNEXUS_NO_GITIGNORE; + // These tests expect nested rules to apply, so a value inherited from the + // invoking shell must not switch them off. afterEach restores it. + delete process.env.GITNEXUS_NO_GITIGNORE; + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-nested-ignore-test-')); + }); + + afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); + if (originalNoGitignore === undefined) { + delete process.env.GITNEXUS_NO_GITIGNORE; + } else { + process.env.GITNEXUS_NO_GITIGNORE = originalNoGitignore; + } + }); + + it('applies a nested .gitignore relative to its own directory', async () => { + await fs.mkdir(path.join(tmpDir, 'app', 'public', 'generated'), { recursive: true }); + await fs.writeFile(path.join(tmpDir, 'app', '.gitignore'), 'public/generated/\n*.log\n'); + const filter = await createIgnoreFilter(tmpDir); + + expect(filter.childrenIgnored(asPath('app/public/generated'))).toBe(true); + expect(filter.ignored(asPath('app/public/generated/bundle.js'))).toBe(true); + expect(filter.ignored(asPath('app/debug.log'))).toBe(true); + expect(filter.ignored(asPath('app/src/deep/debug.log'))).toBe(true); + + // Rules stay scoped to the directory that declares them. + expect(filter.childrenIgnored(asPath('public/generated'))).toBe(false); + expect(filter.ignored(asPath('debug.log'))).toBe(false); + expect(filter.ignored(asPath('other/debug.log'))).toBe(false); + expect(filter.ignored(asPath('app/src/index.ts'))).toBe(false); + }); + + it('preserves independent root exclusions beneath a GitNexus directory negation', async () => { + await fs.mkdir(path.join(tmpDir, 'pkg', 'generated'), { recursive: true }); + await fs.writeFile(path.join(tmpDir, '.gitignore'), '*.log\n**/pkg/generated/\n'); + await fs.writeFile(path.join(tmpDir, '.gitnexusignore'), '!pkg/\n'); + const filter = await createIgnoreFilter(tmpDir); + + expect(filter.childrenIgnored(asPath('pkg'))).toBe(false); + expect(filter.ignored(asPath('pkg/debug.log'))).toBe(true); + expect(filter.childrenIgnored(asPath('pkg/generated'))).toBe(true); + expect(filter.ignored(asPath('pkg/generated/index.ts'))).toBe(true); + expect(filter.ignored(asPath('pkg/index.ts'))).toBe(false); + }); + + it('preserves root inclusions after an unrelated nested directory negation', async () => { + await fs.mkdir(path.join(tmpDir, 'pkg', 'reports', '__tests__'), { recursive: true }); + await fs.writeFile(path.join(tmpDir, '.gitignore'), '!__tests__/\n'); + await fs.writeFile(path.join(tmpDir, 'pkg', '.gitignore'), '!reports/\n'); + await fs.writeFile(path.join(tmpDir, 'pkg', 'reports', '__tests__', 'test.ts'), 'export {};\n'); + const filter = await createIgnoreFilter(tmpDir); + + expect(filter.childrenIgnored(asPath('pkg/reports/__tests__'))).toBe(false); + expect(filter.ignored(asPath('pkg/reports/__tests__/test.ts'))).toBe(false); + const { walkRepositoryPaths } = await import('../../src/core/ingestion/filesystem-walker.js'); + expect((await walkRepositoryPaths(tmpDir)).map((f) => f.path)).toContain( + 'pkg/reports/__tests__/test.ts', + ); + }); + + it('lets a deeper nested negation re-include what an outer nested file ignored', async () => { + await fs.mkdir(path.join(tmpDir, 'app', 'lib'), { recursive: true }); + await fs.writeFile(path.join(tmpDir, 'app', '.gitignore'), '*.gen.ts\n'); + await fs.writeFile(path.join(tmpDir, 'app', 'lib', '.gitignore'), '!keep.gen.ts\n'); + const filter = await createIgnoreFilter(tmpDir); + + expect(filter.ignored(asPath('app/lib/keep.gen.ts'))).toBe(false); + expect(filter.ignored(asPath('app/lib/other.gen.ts'))).toBe(true); + }); + + it('lets a nested negation re-include what the root .gitignore ignored', async () => { + await fs.mkdir(path.join(tmpDir, 'pkg', 'reports'), { recursive: true }); + await fs.writeFile(path.join(tmpDir, '.gitignore'), '*.log\nreports/\n'); + await fs.writeFile(path.join(tmpDir, 'pkg', '.gitignore'), '!keep.log\n!reports/\n'); + const filter = await createIgnoreFilter(tmpDir); + + expect(filter.ignored(asPath('pkg/keep.log'))).toBe(false); + expect(filter.ignored(asPath('pkg/other.log'))).toBe(true); + expect(filter.childrenIgnored(asPath('pkg/reports'))).toBe(false); + expect(filter.childrenIgnored(asPath('reports'))).toBe(true); + }); + + it('re-includes the files inside a directory a nested negation un-ignores', async () => { + await fs.mkdir(path.join(tmpDir, 'pkg', 'reports', 'daily'), { recursive: true }); + await fs.writeFile(path.join(tmpDir, '.gitignore'), 'reports/\n'); + await fs.writeFile(path.join(tmpDir, 'pkg', '.gitignore'), '!reports/\n'); + await fs.writeFile(path.join(tmpDir, 'pkg', 'reports', 'summary.ts'), 'export {};\n'); + await fs.writeFile(path.join(tmpDir, 'pkg', 'reports', 'daily', 'run.ts'), 'export {};\n'); + await fs.mkdir(path.join(tmpDir, 'reports'), { recursive: true }); + await fs.writeFile(path.join(tmpDir, 'reports', 'top.ts'), 'export {};\n'); + const filter = await createIgnoreFilter(tmpDir); + + expect(filter.ignored(asPath('pkg/reports/summary.ts'))).toBe(false); + expect(filter.childrenIgnored(asPath('pkg/reports/daily'))).toBe(false); + expect(filter.ignored(asPath('pkg/reports/daily/run.ts'))).toBe(false); + expect(filter.ignored(asPath('reports/top.ts'))).toBe(true); + + const { walkRepositoryPaths } = await import('../../src/core/ingestion/filesystem-walker.js'); + const scanned = (await walkRepositoryPaths(tmpDir)).map((f) => f.path); + + expect(scanned).toContain('pkg/reports/summary.ts'); + expect(scanned).toContain('pkg/reports/daily/run.ts'); + expect(scanned).not.toContain('reports/top.ts'); + }); + + it('keeps independent root exclusions inside a directory re-included by nested rules', async () => { + await fs.mkdir(path.join(tmpDir, 'pkg', 'reports', 'private'), { recursive: true }); + await fs.writeFile(path.join(tmpDir, '.gitignore'), '*.ts\nreports/\n**/reports/private/\n'); + await fs.writeFile(path.join(tmpDir, 'pkg', '.gitignore'), '!reports/\n'); + await fs.writeFile(path.join(tmpDir, 'pkg', 'reports', 'file.ts'), 'export {};\n'); + await fs.writeFile(path.join(tmpDir, 'pkg', 'reports', 'keep.js'), 'export {};\n'); + await fs.writeFile(path.join(tmpDir, 'pkg', 'reports', 'private', 'secret.js'), 'export {};\n'); + + const { walkRepositoryPaths } = await import('../../src/core/ingestion/filesystem-walker.js'); + const scanned = (await walkRepositoryPaths(tmpDir)).map((f) => f.path); + + expect(scanned).toContain('pkg/reports/keep.js'); + expect(scanned).not.toContain('pkg/reports/file.ts'); + expect(scanned).not.toContain('pkg/reports/private/secret.js'); + }); + + it('re-includes descendants when a deeper directory negation overrides an outer nested file', async () => { + await fs.mkdir(path.join(tmpDir, 'app', 'pkg', 'reports', 'daily'), { recursive: true }); + await fs.writeFile(path.join(tmpDir, 'app', '.gitignore'), 'reports/\n*.gen.ts\n'); + await fs.writeFile(path.join(tmpDir, 'app', 'pkg', '.gitignore'), '!reports/\n'); + await fs.writeFile(path.join(tmpDir, 'app', 'pkg', 'reports', 'keep.ts'), 'export {};\n'); + await fs.writeFile(path.join(tmpDir, 'app', 'pkg', 'reports', 'drop.gen.ts'), 'export {};\n'); + await fs.writeFile( + path.join(tmpDir, 'app', 'pkg', 'reports', 'daily', 'run.ts'), + 'export {};\n', + ); + + const { walkRepositoryPaths } = await import('../../src/core/ingestion/filesystem-walker.js'); + const scanned = (await walkRepositoryPaths(tmpDir)).map((f) => f.path); + + expect(scanned).toContain('app/pkg/reports/keep.ts'); + expect(scanned).toContain('app/pkg/reports/daily/run.ts'); + expect(scanned).not.toContain('app/pkg/reports/drop.gen.ts'); + }); + + it.each(['reports [daily]', ...(process.platform === 'win32' ? [] : ['reports\ndaily'])])( + 'keeps re-included directory names literal: %j', + async (directory) => { + await fs.mkdir(path.join(tmpDir, 'pkg', directory), { recursive: true }); + await fs.mkdir(path.join(tmpDir, 'other', directory), { recursive: true }); + await fs.writeFile(path.join(tmpDir, '.gitignore'), 'reports*/\n*.ts\n'); + await fs.writeFile(path.join(tmpDir, 'pkg', '.gitignore'), '!reports*/\n'); + await fs.writeFile(path.join(tmpDir, 'pkg', directory, 'keep.js'), 'export {};\n'); + await fs.writeFile(path.join(tmpDir, 'pkg', directory, 'drop.ts'), 'export {};\n'); + await fs.writeFile(path.join(tmpDir, 'other', directory, 'drop.js'), 'export {};\n'); + + const { walkRepositoryPaths } = await import('../../src/core/ingestion/filesystem-walker.js'); + const scanned = (await walkRepositoryPaths(tmpDir)).map((f) => f.path); + + expect(scanned).toContain(`pkg/${directory}/keep.js`); + expect(scanned).not.toContain(`pkg/${directory}/drop.ts`); + expect(scanned).not.toContain(`other/${directory}/drop.js`); + }, + ); + + it('keeps .gitnexusignore above a nested negation', async () => { + await fs.mkdir(path.join(tmpDir, 'pkg'), { recursive: true }); + await fs.writeFile(path.join(tmpDir, 'pkg', '.gitignore'), '!keep.log\n'); + await fs.writeFile(path.join(tmpDir, '.gitnexusignore'), 'pkg/keep.log\n'); + const filter = await createIgnoreFilter(tmpDir); + + expect(filter.ignored(asPath('pkg/keep.log'))).toBe(true); + }); + + it('keeps an explicit root .gitnexusignore negation in charge', async () => { + await fs.mkdir(path.join(tmpDir, 'app'), { recursive: true }); + await fs.writeFile(path.join(tmpDir, 'app', '.gitignore'), 'generated/\n'); + await fs.writeFile(path.join(tmpDir, '.gitnexusignore'), '!app/generated/\n'); + const filter = await createIgnoreFilter(tmpDir); + + expect(filter.childrenIgnored(asPath('app/generated'))).toBe(false); + expect(filter.ignored(asPath('app/generated/schema.ts'))).toBe(false); + }); + + it('lets a nested ignore beat a root .gitignore file negation', async () => { + await fs.mkdir(path.join(tmpDir, 'pkg'), { recursive: true }); + await fs.writeFile(path.join(tmpDir, '.gitignore'), '*.log\n!pkg/keep.log\n'); + await fs.writeFile(path.join(tmpDir, 'pkg', '.gitignore'), 'keep.log\n'); + const filter = await createIgnoreFilter(tmpDir); + + expect(filter.ignored(asPath('pkg/keep.log'))).toBe(true); + }); + + it('lets a nested ignore beat a root .gitignore directory negation', async () => { + await fs.mkdir(path.join(tmpDir, 'app', 'generated'), { recursive: true }); + await fs.writeFile(path.join(tmpDir, '.gitignore'), '!app/generated/\n'); + await fs.writeFile(path.join(tmpDir, 'app', '.gitignore'), 'generated/\n'); + const filter = await createIgnoreFilter(tmpDir); + + expect(filter.childrenIgnored(asPath('app/generated'))).toBe(true); + expect(filter.ignored(asPath('app/generated/schema.ts'))).toBe(true); + }); + + it('does not let a nested negation rescue hardcoded defaults', async () => { + await fs.mkdir(path.join(tmpDir, 'pkg', 'node_modules'), { recursive: true }); + await fs.writeFile( + path.join(tmpDir, 'pkg', '.gitignore'), + '!node_modules/\n!package-lock.json\n', + ); + const filter = await createIgnoreFilter(tmpDir); + + expect(filter.childrenIgnored(asPath('pkg/node_modules'))).toBe(true); + expect(filter.ignored(asPath('pkg/package-lock.json'))).toBe(true); + }); + + it.skipIf(process.platform === 'win32')('ignores a symlinked nested .gitignore', async () => { + await fs.mkdir(path.join(tmpDir, 'app'), { recursive: true }); + await fs.writeFile(path.join(tmpDir, 'rules.txt'), '*.log\n'); + await fs.symlink(path.join(tmpDir, 'rules.txt'), path.join(tmpDir, 'app', '.gitignore')); + const filter = await createIgnoreFilter(tmpDir); + + expect(filter.ignored(asPath('app/debug.log'))).toBe(false); + }); + + it('skips nested .gitignore files when GITNEXUS_NO_GITIGNORE is set', async () => { + await fs.mkdir(path.join(tmpDir, 'app'), { recursive: true }); + await fs.writeFile(path.join(tmpDir, 'app', '.gitignore'), 'generated/\n'); + process.env.GITNEXUS_NO_GITIGNORE = '1'; + const filter = await createIgnoreFilter(tmpDir); + + expect(filter.childrenIgnored(asPath('app/generated'))).toBe(false); + }); + + it('prunes nested-ignored files from a real repository walk', async () => { + await fs.mkdir(path.join(tmpDir, 'app', 'src'), { recursive: true }); + await fs.mkdir(path.join(tmpDir, 'app', 'public', 'generated'), { recursive: true }); + await fs.writeFile(path.join(tmpDir, 'app', '.gitignore'), 'public/generated/\n'); + await fs.writeFile(path.join(tmpDir, 'app', 'src', 'index.ts'), 'export {};\n'); + await fs.writeFile(path.join(tmpDir, 'app', 'public', 'generated', 'bundle.js'), 'x;\n'); + + const { walkRepositoryPaths } = await import('../../src/core/ingestion/filesystem-walker.js'); + const scanned = (await walkRepositoryPaths(tmpDir)).map((f) => f.path); + + expect(scanned).toContain('app/src/index.ts'); + expect(scanned).not.toContain('app/public/generated/bundle.js'); + }); + + it('follows git when a nested file negates a path inside an ignored directory', async () => { + // git cannot re-include a file whose parent directory is excluded, so + // `gen/` + `!gen/keep.ts` leaves keep.ts out, while `gen/*` excludes only + // the contents and lets the negation bring keep.ts back. Root rules behave + // the same way. (`gen`, not `build`: `build` is a hardcoded default.) + const { walkRepositoryPaths } = await import('../../src/core/ingestion/filesystem-walker.js'); + for (const [pkg, rules] of [ + ['dir', 'gen/\n!gen/keep.ts\n'], + ['star', 'gen/*\n!gen/keep.ts\n'], + ]) { + await fs.mkdir(path.join(tmpDir, pkg, 'gen'), { recursive: true }); + await fs.writeFile(path.join(tmpDir, pkg, '.gitignore'), rules); + await fs.writeFile(path.join(tmpDir, pkg, 'gen', 'keep.ts'), 'export {};\n'); + await fs.writeFile(path.join(tmpDir, pkg, 'gen', 'drop.ts'), 'export {};\n'); + } + + const scanned = (await walkRepositoryPaths(tmpDir)).map((f) => f.path); + + expect(scanned).not.toContain('dir/gen/keep.ts'); + expect(scanned).not.toContain('dir/gen/drop.ts'); + expect(scanned).toContain('star/gen/keep.ts'); + expect(scanned).not.toContain('star/gen/drop.ts'); + }); + + it.skipIf(process.platform === 'win32')( + 'does not hang on a nested .gitignore that is a FIFO', + async () => { + // Opening a FIFO for reading blocks until a writer appears. Without + // O_NONBLOCK the walk would stop in open(2), before the regular-file + // check, and never return. + await fs.mkdir(path.join(tmpDir, 'pkg', 'src'), { recursive: true }); + await fs.writeFile(path.join(tmpDir, 'pkg', 'src', 'index.ts'), 'export {};\n'); + execFileSync('mkfifo', [path.join(tmpDir, 'pkg', '.gitignore')]); + + // A timer in this worker cannot interrupt a blocked synchronous open. + // Enforce the deadline outside the process that performs the walk. + const walkerUrl = new URL('../../src/core/ingestion/filesystem-walker.ts', import.meta.url) + .href; + const output = execFileSync( + process.execPath, + [ + '--import', + import.meta.resolve('tsx'), + '--input-type=module', + '--eval', + `import { walkRepositoryPaths } from ${JSON.stringify(walkerUrl)}; + const files = await walkRepositoryPaths(${JSON.stringify(tmpDir)}); + console.log(JSON.stringify(files.map((file) => file.path)));`, + ], + { + encoding: 'utf8', + timeout: 5_000, + killSignal: 'SIGKILL', + env: { ...process.env, GITNEXUS_NO_GLOBAL_IGNORE: '1' }, + }, + ); + + expect(JSON.parse(output)).toContain('pkg/src/index.ts'); + }, + 10_000, + ); +}); + describe('loadIgnoreRules — error handling', () => { let tmpDir: string;