mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-07 02:58:02 +00:00
fix(config): honor nested .gitignore files during repository walks (#3440)
This commit is contained in:
parent
d1971cf953
commit
a47cd17f27
2 changed files with 517 additions and 1 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue