fix(config): honor nested .gitignore files during repository walks (#3440)

This commit is contained in:
Abhishek B R 2026-10-03 15:25:47 +05:30 • committed by GitHub
parent d1971cf953
commit a47cd17f27
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 517 additions and 1 deletions

View file

@ -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<string, DirectoryContext>([
[
'',
{
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

View file

@ -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;