GitNexus/gitnexus/test/unit/ignore-service.test.ts

1237 lines
52 KiB
TypeScript

import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach, vi } from 'vitest';
import fs from 'fs/promises';
import path from 'path';
import os from 'os';
import { execFileSync } from 'child_process';
import {
shouldIgnorePath,
isHardcodedIgnoredDirectory,
loadIgnoreRules,
createIgnoreFilter,
} from '../../src/config/ignore-service.js';
import { _captureLogger } from '../../src/core/logger.js';
import * as git from '../../src/storage/git.js';
// Only the two functions loadIgnoreRules calls are mocked (#2606) — real git
// repos/config are exercised separately in git.test.ts; here the goal is
// hermetic coverage of loadIgnoreRules' precedence wiring.
vi.mock('../../src/storage/git.js', () => ({
getCoreExcludesFilePath: vi.fn(),
getGitInfoExcludePath: vi.fn(),
}));
// Every other describe block in this file calls loadIgnoreRules/
// createIgnoreFilter without expecting a global-ignore layer — default both
// mocks to "nothing there" (a path that can't exist, and null respectively)
// so pre-existing scenarios stay unaffected. The #2606 block below overrides
// per test.
const NONEXISTENT_CORE_EXCLUDES_PATH = path.join(
os.tmpdir(),
'gn-ignore-service-test-nonexistent-core-excludes-file',
);
beforeEach(() => {
vi.mocked(git.getCoreExcludesFilePath).mockReturnValue(NONEXISTENT_CORE_EXCLUDES_PATH);
vi.mocked(git.getGitInfoExcludePath).mockReturnValue(null);
});
describe('shouldIgnorePath', () => {
describe('version control directories', () => {
it.each(['.git', '.svn', '.hg', '.bzr'])('ignores %s directory', (dir) => {
expect(shouldIgnorePath(`${dir}/config`)).toBe(true);
expect(shouldIgnorePath(`project/${dir}/HEAD`)).toBe(true);
});
});
describe('IDE/editor directories', () => {
it.each(['.idea', '.vscode', '.vs'])('ignores %s directory', (dir) => {
expect(shouldIgnorePath(`${dir}/settings.json`)).toBe(true);
});
});
describe('dependency directories', () => {
it.each([
'node_modules',
'vendor',
'third_party',
'3rdparty',
'venv',
'.venv',
'__pycache__',
'site-packages',
'.mypy_cache',
'.pytest_cache',
])('ignores %s directory', (dir) => {
expect(shouldIgnorePath(`project/${dir}/some-file.js`)).toBe(true);
});
});
describe('build output directories', () => {
it.each([
'dist',
'build',
'out',
'output',
'bin',
'obj',
'target',
'.next',
'.nuxt',
'.vercel',
'.parcel-cache',
'.turbo',
])('ignores %s directory', (dir) => {
expect(shouldIgnorePath(`${dir}/bundle.js`)).toBe(true);
});
});
describe('test/coverage directories', () => {
it.each(['coverage', '__tests__', '__mocks__', '.nyc_output'])(
'ignores %s directory',
(dir) => {
expect(shouldIgnorePath(`${dir}/results.json`)).toBe(true);
},
);
});
describe('ignored file extensions', () => {
it.each([
// Images
'.png',
'.jpg',
'.jpeg',
'.gif',
'.svg',
'.ico',
'.webp',
// Archives
'.zip',
'.tar',
'.gz',
'.rar',
// Binary/Compiled
'.exe',
'.dll',
'.so',
'.dylib',
'.class',
'.jar',
'.pyc',
'.wasm',
// Documents
'.pdf',
'.doc',
'.docx',
// Media
'.mp4',
'.mp3',
'.wav',
// Fonts
'.woff',
'.woff2',
'.ttf',
// Databases
'.db',
'.sqlite',
// Source maps
'.map',
// Lock files
'.lock',
// Certificates
'.pem',
'.key',
'.crt',
// Data files
'.csv',
'.parquet',
'.pkl',
])('ignores files with %s extension', (ext) => {
expect(shouldIgnorePath(`assets/file${ext}`)).toBe(true);
});
});
describe('ignored files by exact name', () => {
it.each([
'package-lock.json',
'yarn.lock',
'pnpm-lock.yaml',
'composer.lock',
'Cargo.lock',
'go.sum',
'.gitignore',
'.gitattributes',
'.npmrc',
'.editorconfig',
'.prettierrc',
'.eslintignore',
'.dockerignore',
'LICENSE',
'LICENSE.md',
'CHANGELOG.md',
'.env',
'.env.local',
'.env.production',
])('ignores %s', (fileName) => {
expect(shouldIgnorePath(fileName)).toBe(true);
expect(shouldIgnorePath(`project/${fileName}`)).toBe(true);
});
});
describe('compound extensions', () => {
it('ignores .min.js files', () => {
expect(shouldIgnorePath('dist/bundle.min.js')).toBe(true);
});
it('ignores .bundle.js files', () => {
expect(shouldIgnorePath('dist/app.bundle.js')).toBe(true);
});
it('ignores .chunk.js files', () => {
expect(shouldIgnorePath('dist/vendor.chunk.js')).toBe(true);
});
it('ignores .min.css files', () => {
expect(shouldIgnorePath('dist/styles.min.css')).toBe(true);
});
});
describe('generated files', () => {
it('ignores .generated. files', () => {
expect(shouldIgnorePath('src/api.generated.ts')).toBe(true);
});
it('ignores generated Monaco editor worker bundles', () => {
expect(shouldIgnorePath('public/monaco-workers/json.worker.js')).toBe(true);
expect(shouldIgnorePath('keep-ui/public/monaco-workers/125.js')).toBe(true);
});
it('keeps tracked TypeScript declaration files discoverable', () => {
expect(shouldIgnorePath('types/index.d.ts')).toBe(false);
});
it('ignores Laravel compiled Blade view cache files', () => {
expect(shouldIgnorePath('storage/framework/views/1a2b3c.php')).toBe(true);
expect(shouldIgnorePath('project/storage/framework/views/1a2b3c.php')).toBe(true);
expect(shouldIgnorePath('project\\storage\\framework\\views\\1a2b3c.php')).toBe(true);
});
});
describe('Windows path normalization', () => {
it('normalizes backslashes to forward slashes', () => {
expect(shouldIgnorePath('node_modules\\express\\index.js')).toBe(true);
expect(shouldIgnorePath('project\\.git\\HEAD')).toBe(true);
});
});
describe('files that should NOT be ignored', () => {
it.each([
'src/index.ts',
'src/components/Button.tsx',
'apps/client/src/shared/env/getAppEnv.ts',
'packages/ai/src/generated/bundle.ts',
'apps/client/src/vite-env.d.ts',
'Generated/client.cs',
'Env/settings.ts',
'ENV/config.ts',
'lib/utils.py',
'cmd/server/main.go',
'src/main.rs',
'app/Models/User.php',
'Sources/App.swift',
'src/App.java',
'src/main.c',
'src/main.cpp',
'src/Program.cs',
])('does not ignore source file %s', (filePath) => {
expect(shouldIgnorePath(filePath)).toBe(false);
});
it.each(['env/pyvenv.cfg', 'env/settings.py', 'generated/client.ts'])(
'prunes ambiguous artifact directories only at the repository root: %s',
(filePath) => {
expect(shouldIgnorePath(filePath)).toBe(true);
},
);
});
});
describe('isHardcodedIgnoredDirectory', () => {
it('returns true for known ignored directories', () => {
expect(isHardcodedIgnoredDirectory('node_modules')).toBe(true);
expect(isHardcodedIgnoredDirectory('.git')).toBe(true);
expect(isHardcodedIgnoredDirectory('dist')).toBe(true);
expect(isHardcodedIgnoredDirectory('monaco-workers')).toBe(true);
expect(isHardcodedIgnoredDirectory('__pycache__')).toBe(true);
expect(isHardcodedIgnoredDirectory('dist-packages')).toBe(true);
});
it('returns false for source directories', () => {
expect(isHardcodedIgnoredDirectory('src')).toBe(false);
expect(isHardcodedIgnoredDirectory('lib')).toBe(false);
expect(isHardcodedIgnoredDirectory('app')).toBe(false);
expect(isHardcodedIgnoredDirectory('local')).toBe(false);
expect(isHardcodedIgnoredDirectory('env')).toBe(false);
expect(isHardcodedIgnoredDirectory('generated')).toBe(false);
});
});
// ─── .gitnexusignore negation can override hardcoded list (#771) ────
//
// Per @magyargergo's review: `.gitnexusignore` should honour
// `.gitignore`-style negation against the hardcoded DEFAULT_IGNORE_LIST.
// A `!__tests__/` line in `.gitnexusignore` must re-enable indexing of
// `__tests__/` even though the hardcoded list would normally block it.
// These tests exercise the full `createIgnoreFilter` surface with real
// temp files (the negation logic lives in `createIgnoreFilter`, not in
// `shouldIgnorePath` — the latter stays pure-hardcoded for callers like
// the wiki generator that don't have per-repo config context).
//
// Locks in:
// 1. Default (no .gitnexusignore) — hardcoded list still blocks
// __tests__ / __mocks__ / node_modules (byte-identical pre-#771).
// 2. `!__tests__/` negation — __tests__ and its descendants are
// indexed; other hardcoded entries (node_modules, .git) stay
// blocked.
// 3. Broader negation (e.g. `!node_modules/`) also works — design is
// general, not special-cased to the 2 test dirs.
// 4. Negation applies both to the directory itself (`childrenIgnored`
// allows descent) AND to descendants (`ignored` allows files).
// 5. `shouldIgnorePath` pure-hardcoded contract is preserved — the
// wiki generator and other callers without per-repo config get
// deterministic behavior.
describe('.gitnexusignore negation overrides hardcoded DEFAULT_IGNORE_LIST (#771)', () => {
let tmpDir: string;
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-ignore-negation-'));
});
afterEach(async () => {
await fs.rm(tmpDir, { recursive: true, force: true });
});
/** Synthetic path-scurry Path helper. `createIgnoreFilter.ignored` /
* `childrenIgnored` only look at `.relative()` and `.name`, so a
* minimal shape with those two is enough to exercise the logic. */
const mkPath = (rel: string) =>
({
relative: () => rel.replace(/\\/g, '/'),
name: rel.split(/[/\\]/).pop() || rel,
}) as unknown as Parameters<Awaited<ReturnType<typeof createIgnoreFilter>>['ignored']>[0];
it('default (no .gitnexusignore): __tests__ still blocked by hardcoded list', async () => {
const filter = await createIgnoreFilter(tmpDir);
expect(filter.ignored(mkPath('__tests__/foo.test.ts'))).toBe(true);
expect(filter.childrenIgnored(mkPath('__tests__'))).toBe(true);
});
it('prunes exact-case root artifacts while allowing nested source directories', async () => {
const filter = await createIgnoreFilter(tmpDir);
expect(filter.childrenIgnored(mkPath('generated'))).toBe(true);
expect(filter.childrenIgnored(mkPath('env'))).toBe(true);
expect(filter.childrenIgnored(mkPath('packages/api/generated'))).toBe(false);
expect(filter.childrenIgnored(mkPath('Generated'))).toBe(false);
expect(filter.childrenIgnored(mkPath('Env'))).toBe(false);
});
// `_next` has to prune the DIRECTORY, not merely reject each file underneath.
// Every measured benefit of ignoring it comes from never enumerating the
// bundle tree, and no file list can show the difference — anything under
// `_next` is rejected either way. `childrenIgnored` is the only observation
// that distinguishes them, so a refactor that moved `_next` to a
// `shouldIgnorePath`-only rule would keep the build-output suite green while
// silently restoring the full walk.
it('prunes emitted _next output as a directory, at any depth', async () => {
const filter = await createIgnoreFilter(tmpDir);
expect(filter.childrenIgnored(mkPath('_next'))).toBe(true);
expect(filter.childrenIgnored(mkPath('android/app/src/main/assets/public/_next'))).toBe(true);
});
it('matches _next as a whole segment, so _nextgen source is still walked', async () => {
const filter = await createIgnoreFilter(tmpDir);
expect(filter.childrenIgnored(mkPath('src/_nextgen'))).toBe(false);
expect(filter.childrenIgnored(mkPath('packages/my_next'))).toBe(false);
});
it('`!_next/` negation unlocks the emitted output directory at any depth', async () => {
// The bare form is the one that works. `!_next/**` alone is a silent no-op:
// `childrenIgnored` prunes the directory before any descendant pattern is
// ever tested, so no file underneath reaches `ignored`.
await fs.writeFile(path.join(tmpDir, '.gitnexusignore'), '!_next/\n');
const filter = await createIgnoreFilter(tmpDir);
expect(filter.childrenIgnored(mkPath('_next'))).toBe(false);
expect(filter.childrenIgnored(mkPath('android/app/src/main/assets/public/_next'))).toBe(false);
});
it('prunes a nested env directory only when pyvenv.cfg identifies a virtual environment', async () => {
await fs.mkdir(path.join(tmpDir, 'backend', 'env'), { recursive: true });
await fs.writeFile(path.join(tmpDir, 'backend', 'env', 'pyvenv.cfg'), 'home = python\n');
const filter = await createIgnoreFilter(tmpDir);
expect(filter.childrenIgnored(mkPath('backend/env'))).toBe(true);
expect(filter.childrenIgnored(mkPath('services/api/env'))).toBe(false);
});
it('`!env/` negation unlocks the root artifact directory', async () => {
await fs.writeFile(path.join(tmpDir, '.gitnexusignore'), '!env/\n');
const filter = await createIgnoreFilter(tmpDir);
expect(filter.childrenIgnored(mkPath('env'))).toBe(false);
expect(filter.ignored(mkPath('env/settings.py'))).toBe(false);
});
it('`!__tests__/` negation unlocks the directory and its descendants', async () => {
await fs.writeFile(path.join(tmpDir, '.gitnexusignore'), '!__tests__/\n');
const filter = await createIgnoreFilter(tmpDir);
expect(filter.childrenIgnored(mkPath('__tests__'))).toBe(false);
expect(filter.ignored(mkPath('__tests__/foo.test.ts'))).toBe(false);
expect(filter.ignored(mkPath('src/__tests__/nested.test.ts'))).toBe(false);
});
it('`!__mocks__/` negation unlocks __mocks__ but NOT __tests__', async () => {
await fs.writeFile(path.join(tmpDir, '.gitnexusignore'), '!__mocks__/\n');
const filter = await createIgnoreFilter(tmpDir);
expect(filter.ignored(mkPath('__mocks__/api.ts'))).toBe(false);
// __tests__ not negated — hardcoded list still blocks it.
expect(filter.ignored(mkPath('__tests__/foo.test.ts'))).toBe(true);
expect(filter.childrenIgnored(mkPath('__tests__'))).toBe(true);
});
it('negation generalises — `!node_modules/` unlocks a different hardcoded entry', async () => {
// The design isn't special-cased to the two names from the issue —
// it honours any negation the user writes. Lock this in with a
// broader example that proves the mechanism, not the dir name.
await fs.writeFile(path.join(tmpDir, '.gitnexusignore'), '!node_modules/\n');
const filter = await createIgnoreFilter(tmpDir);
expect(filter.childrenIgnored(mkPath('node_modules'))).toBe(false);
expect(filter.ignored(mkPath('node_modules/express/index.js'))).toBe(false);
});
it('`!parts/` unlocks Java modules and nested parts directories (#2673)', async () => {
await fs.writeFile(path.join(tmpDir, '.gitnexusignore'), '!parts/\n');
const filter = await createIgnoreFilter(tmpDir);
expect(filter.childrenIgnored(mkPath('parts'))).toBe(false);
expect(filter.ignored(mkPath('parts/src/main/java/com/example/Part.java'))).toBe(false);
expect(filter.childrenIgnored(mkPath('admin/src/main/java/com/example/controller/parts'))).toBe(
false,
);
expect(
filter.ignored(
mkPath('admin/src/main/java/com/example/controller/parts/PartsController.java'),
),
).toBe(false);
});
it('negation of one hardcoded entry does not leak to others', async () => {
await fs.writeFile(path.join(tmpDir, '.gitnexusignore'), '!__tests__/\n');
const filter = await createIgnoreFilter(tmpDir);
// __tests__ negated → allowed.
expect(filter.ignored(mkPath('__tests__/foo.test.ts'))).toBe(false);
// But node_modules / .git / dist not negated → still blocked.
expect(filter.ignored(mkPath('node_modules/pkg/index.js'))).toBe(true);
expect(filter.ignored(mkPath('.git/HEAD'))).toBe(true);
expect(filter.ignored(mkPath('dist/bundle.js'))).toBe(true);
expect(filter.childrenIgnored(mkPath('node_modules'))).toBe(true);
expect(filter.childrenIgnored(mkPath('.git'))).toBe(true);
});
it('explicit negation can still opt into generated Monaco worker bundles', async () => {
await fs.writeFile(path.join(tmpDir, '.gitnexusignore'), '!public/monaco-workers/\n');
const filter = await createIgnoreFilter(tmpDir);
expect(filter.childrenIgnored(mkPath('public/monaco-workers'))).toBe(false);
expect(filter.ignored(mkPath('public/monaco-workers/json.worker.js'))).toBe(false);
});
it('standard `.gitignore` rules (no negation) still layer on top of hardcoded', async () => {
// Pre-#771 behaviour: if .gitnexusignore says `my-dir/`, that dir
// is ignored in addition to the hardcoded list. Non-negation
// rules are unaffected by this PR.
await fs.writeFile(path.join(tmpDir, '.gitnexusignore'), 'my-dir/\n');
const filter = await createIgnoreFilter(tmpDir);
expect(filter.ignored(mkPath('my-dir/file.ts'))).toBe(true);
expect(filter.childrenIgnored(mkPath('my-dir'))).toBe(true);
// Hardcoded still blocks unaffected paths.
expect(filter.ignored(mkPath('node_modules/foo.js'))).toBe(true);
});
it('`!parent/` + `parent/child/` re-ignore: child still blocked (last-match-wins)', async () => {
// .gitignore semantics: a later more-specific rule overrides an
// earlier negation. The negation unlocks the hardcoded block on
// `__tests__/`, but the subsequent `__tests__/generated/` line
// re-ignores that subset. `__tests__/foo.test.ts` stays allowed;
// `__tests__/generated/foo.ts` stays blocked. This locks in the
// guarantee the design comment makes about "standard rules still
// layer on top" for the compound case.
await fs.writeFile(path.join(tmpDir, '.gitnexusignore'), '!__tests__/\n__tests__/generated/\n');
const filter = await createIgnoreFilter(tmpDir);
// Parent negation still in effect: top-level tests allowed.
expect(filter.ignored(mkPath('__tests__/foo.test.ts'))).toBe(false);
expect(filter.childrenIgnored(mkPath('__tests__'))).toBe(false);
// Re-ignored subdirectory: children blocked at file level AND at
// the directory-descent level, so ingestion never walks in.
expect(filter.ignored(mkPath('__tests__/generated/foo.ts'))).toBe(true);
expect(filter.childrenIgnored(mkPath('__tests__/generated'))).toBe(true);
});
it('shouldIgnorePath (raw hardcoded check) is unchanged — wiki / external callers unaffected', async () => {
// `shouldIgnorePath` is called from `core/wiki/generator.ts` and
// doesn't have access to per-repo `.gitnexusignore` config. Its
// contract stays "is this path in the hardcoded list?". The #771
// negation override lives only inside `createIgnoreFilter`, which
// IS called with config context. This asymmetry is deliberate.
expect(shouldIgnorePath('__tests__/foo.test.ts')).toBe(true);
expect(shouldIgnorePath('__mocks__/api.ts')).toBe(true);
expect(shouldIgnorePath('node_modules/pkg/index.js')).toBe(true);
});
it('isHardcodedIgnoredDirectory (raw membership) unchanged by negation', async () => {
// Pure membership query — the list itself doesn't mutate.
expect(isHardcodedIgnoredDirectory('__tests__')).toBe(true);
expect(isHardcodedIgnoredDirectory('__mocks__')).toBe(true);
expect(isHardcodedIgnoredDirectory('node_modules')).toBe(true);
});
});
describe('loadIgnoreRules', () => {
let tmpDir: string;
beforeAll(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-ignore-test-'));
});
afterAll(async () => {
await fs.rm(tmpDir, { recursive: true, force: true });
});
it('returns null when no ignore files exist', async () => {
const result = await loadIgnoreRules(tmpDir);
expect(result).toBeNull();
});
it('parses .gitignore file', async () => {
await fs.writeFile(path.join(tmpDir, '.gitignore'), 'data/\nlogs/\n');
const ig = await loadIgnoreRules(tmpDir);
expect(ig).not.toBeNull();
expect(ig!.ignores('data/file.txt')).toBe(true);
expect(ig!.ignores('logs/app.log')).toBe(true);
expect(ig!.ignores('src/index.ts')).toBe(false);
await fs.unlink(path.join(tmpDir, '.gitignore'));
});
it('parses .gitnexusignore file', async () => {
await fs.writeFile(path.join(tmpDir, '.gitnexusignore'), 'vendor/\n*.test.ts\n');
const ig = await loadIgnoreRules(tmpDir);
expect(ig).not.toBeNull();
expect(ig!.ignores('vendor/lib.js')).toBe(true);
expect(ig!.ignores('src/app.test.ts')).toBe(true);
expect(ig!.ignores('src/app.ts')).toBe(false);
await fs.unlink(path.join(tmpDir, '.gitnexusignore'));
});
it('combines both files', async () => {
await fs.writeFile(path.join(tmpDir, '.gitignore'), 'data/\n');
await fs.writeFile(path.join(tmpDir, '.gitnexusignore'), 'vendor/\n');
const ig = await loadIgnoreRules(tmpDir);
expect(ig).not.toBeNull();
expect(ig!.ignores('data/file.txt')).toBe(true);
expect(ig!.ignores('vendor/lib.js')).toBe(true);
expect(ig!.ignores('src/index.ts')).toBe(false);
await fs.unlink(path.join(tmpDir, '.gitignore'));
await fs.unlink(path.join(tmpDir, '.gitnexusignore'));
});
it('handles comments and blank lines', async () => {
await fs.writeFile(
path.join(tmpDir, '.gitignore'),
'# comment\n\ndata/\n\n# another comment\n',
);
const ig = await loadIgnoreRules(tmpDir);
expect(ig).not.toBeNull();
expect(ig!.ignores('data/file.txt')).toBe(true);
expect(ig!.ignores('src/index.ts')).toBe(false);
await fs.unlink(path.join(tmpDir, '.gitignore'));
});
});
describe('createIgnoreFilter', () => {
let tmpDir: string;
beforeAll(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-filter-test-'));
});
afterAll(async () => {
await fs.rm(tmpDir, { recursive: true, force: true });
});
it('creates a filter with ignored and childrenIgnored methods', async () => {
const filter = await createIgnoreFilter(tmpDir);
expect(typeof filter.ignored).toBe('function');
expect(typeof filter.childrenIgnored).toBe('function');
});
it('childrenIgnored returns true for hardcoded directories', async () => {
const filter = await createIgnoreFilter(tmpDir);
// Simulate a Path-like object
const mockPath = { name: 'node_modules', relative: () => 'node_modules' } as any;
expect(filter.childrenIgnored(mockPath)).toBe(true);
const srcPath = { name: 'src', relative: () => 'src' } as any;
expect(filter.childrenIgnored(srcPath)).toBe(false);
});
it('childrenIgnored returns true for gitignored directories', async () => {
await fs.writeFile(path.join(tmpDir, '.gitignore'), 'local/\n');
const filter = await createIgnoreFilter(tmpDir);
const localPath = { name: 'local', relative: () => 'local' } as any;
expect(filter.childrenIgnored(localPath)).toBe(true);
const srcPath = { name: 'src', relative: () => 'src' } as any;
expect(filter.childrenIgnored(srcPath)).toBe(false);
await fs.unlink(path.join(tmpDir, '.gitignore'));
});
it('childrenIgnored returns true for bare-name directory patterns (no trailing slash)', async () => {
await fs.writeFile(path.join(tmpDir, '.gitignore'), 'local\n');
const filter = await createIgnoreFilter(tmpDir);
const localPath = { name: 'local', relative: () => 'local' } as any;
expect(filter.childrenIgnored(localPath)).toBe(true);
const srcPath = { name: 'src', relative: () => 'src' } as any;
expect(filter.childrenIgnored(srcPath)).toBe(false);
await fs.unlink(path.join(tmpDir, '.gitignore'));
});
it('childrenIgnored respects negation patterns (exclude-all + whitelist)', async () => {
// Reproduces https://github.com/abhigyanpatwari/GitNexus/issues/596
// Pattern: `*` (exclude all) + `!iOS/` + `!iOS/**` (whitelist iOS)
await fs.writeFile(
path.join(tmpDir, '.gitnexusignore'),
'*\n!iOS/\n!iOS/**\n!backend/\n!backend/living_plan/\n!backend/living_plan/**\n',
);
const filter = await createIgnoreFilter(tmpDir);
// Whitelisted directories must NOT be pruned
const iosPath = { name: 'iOS', relative: () => 'iOS' } as any;
expect(filter.childrenIgnored(iosPath)).toBe(false);
const backendPath = { name: 'backend', relative: () => 'backend' } as any;
expect(filter.childrenIgnored(backendPath)).toBe(false);
const livingPlanPath = { name: 'living_plan', relative: () => 'backend/living_plan' } as any;
expect(filter.childrenIgnored(livingPlanPath)).toBe(false);
// Non-whitelisted directories must still be pruned
const srcPath = { name: 'src', relative: () => 'src' } as any;
expect(filter.childrenIgnored(srcPath)).toBe(true);
const libPath = { name: 'lib', relative: () => 'lib' } as any;
expect(filter.childrenIgnored(libPath)).toBe(true);
await fs.unlink(path.join(tmpDir, '.gitnexusignore'));
});
it('childrenIgnored respects negation patterns without trailing slash (!dir vs !dir/)', async () => {
// Per gitignore spec: `!iOS` (no slash) negates both files and directories
// named `iOS`, while `!iOS/` is directory-only. The `ignore` package
// normalizes both forms so that `ig.ignores('iOS/')` returns false in either case.
// Ref: https://github.com/kaelzhang/node-ignore#2-filenames-and-dirnames (see #596)
await fs.writeFile(path.join(tmpDir, '.gitnexusignore'), '*\n!iOS\n!iOS/**\n');
const filter = await createIgnoreFilter(tmpDir);
// Bare negation `!iOS` must also un-ignore the iOS/ directory
const iosPath = { name: 'iOS', relative: () => 'iOS' } as any;
expect(filter.childrenIgnored(iosPath)).toBe(false);
// Non-whitelisted directories still pruned
const srcPath = { name: 'src', relative: () => 'src' } as any;
expect(filter.childrenIgnored(srcPath)).toBe(true);
await fs.unlink(path.join(tmpDir, '.gitnexusignore'));
});
it('ignored respects negation patterns for files under whitelisted directories', async () => {
await fs.writeFile(path.join(tmpDir, '.gitnexusignore'), '*\n!iOS/\n!iOS/**\n');
const filter = await createIgnoreFilter(tmpDir);
// Files under whitelisted directory should NOT be ignored
const swiftFile = { name: 'App.swift', relative: () => 'iOS/App.swift' } as any;
expect(filter.ignored(swiftFile)).toBe(false);
// Files outside whitelisted directory should be ignored
const pyFile = { name: 'main.py', relative: () => 'scripts/main.py' } as any;
expect(filter.ignored(pyFile)).toBe(true);
await fs.unlink(path.join(tmpDir, '.gitnexusignore'));
});
it('ignored returns true for file-glob patterns like *.log', async () => {
await fs.writeFile(path.join(tmpDir, '.gitignore'), '*.log\n');
const filter = await createIgnoreFilter(tmpDir);
const logPath = { name: 'app.log', relative: () => 'app.log' } as any;
expect(filter.ignored(logPath)).toBe(true);
const tsPath = { name: 'index.ts', relative: () => 'src/index.ts' } as any;
expect(filter.ignored(tsPath)).toBe(false);
await fs.unlink(path.join(tmpDir, '.gitignore'));
});
});
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;
beforeAll(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-err-test-'));
});
afterAll(async () => {
await fs.rm(tmpDir, { recursive: true, force: true });
});
// Also skip under uid=0: root bypasses POSIX read-permission checks, so
// chmod 000 does NOT trigger EACCES — fs.readFile reads the file anyway
// and loadIgnoreRules returns parsed rules instead of null. This makes
// the test fail in any privileged environment (rootful Docker, CI runners
// configured with root). The non-root branch still exercises the real
// EACCES path; root just can't reproduce the failure mode.
it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)(
'warns on EACCES but does not throw',
async () => {
const gitignorePath = path.join(tmpDir, '.gitignore');
await fs.writeFile(gitignorePath, 'data/\n');
await fs.chmod(gitignorePath, 0o000);
const cap = _captureLogger();
const result = await loadIgnoreRules(tmpDir);
// Should still return (null or partial), not throw
expect(result).toBeNull();
expect(cap.records().some((r) => String(r.msg ?? '').includes('.gitignore'))).toBe(true);
cap.restore();
await fs.chmod(gitignorePath, 0o644);
await fs.unlink(gitignorePath);
},
);
});
describe('loadIgnoreRules — GITNEXUS_NO_GITIGNORE env var', () => {
let tmpDir: string;
beforeAll(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-noignore-test-'));
});
afterAll(async () => {
await fs.rm(tmpDir, { recursive: true, force: true });
});
it('skips .gitignore when GITNEXUS_NO_GITIGNORE is set', async () => {
await fs.writeFile(path.join(tmpDir, '.gitignore'), 'data/\n');
const original = process.env.GITNEXUS_NO_GITIGNORE;
process.env.GITNEXUS_NO_GITIGNORE = '1';
try {
const ig = await loadIgnoreRules(tmpDir);
// .gitignore should be skipped — no rules loaded
expect(ig).toBeNull();
} finally {
if (original === undefined) {
delete process.env.GITNEXUS_NO_GITIGNORE;
} else {
process.env.GITNEXUS_NO_GITIGNORE = original;
}
await fs.unlink(path.join(tmpDir, '.gitignore'));
}
});
it('still reads .gitnexusignore when GITNEXUS_NO_GITIGNORE is set', async () => {
await fs.writeFile(path.join(tmpDir, '.gitnexusignore'), 'vendor/\n');
const original = process.env.GITNEXUS_NO_GITIGNORE;
process.env.GITNEXUS_NO_GITIGNORE = '1';
try {
const ig = await loadIgnoreRules(tmpDir);
expect(ig).not.toBeNull();
expect(ig!.ignores('vendor/lib.js')).toBe(true);
} finally {
if (original === undefined) {
delete process.env.GITNEXUS_NO_GITIGNORE;
} else {
process.env.GITNEXUS_NO_GITIGNORE = original;
}
await fs.unlink(path.join(tmpDir, '.gitnexusignore'));
}
});
});
// ─── Git-native global ignore sources (#2606) ─────────────────────────
//
// IgnoreService previously read only per-repo .gitignore/.gitnexusignore.
// #2606 asked for something that applies across every indexed repo without
// repeating it per repo. Rather than inventing a new file location,
// loadIgnoreRules now reads the same two sources real `git` itself
// consults for exactly this purpose: `core.excludesFile` (git's own
// all-repos global file) and `$GIT_COMMON_DIR/info/exclude` (per-repo,
// untracked — no push/commit access to the repo needed).
//
// Precedence mirrors gitignore(5) exactly: core.excludesFile (lowest) is
// added first, then info/exclude, then .gitignore/.gitnexusignore below —
// each later ig.add() can negate an earlier one, matching git's own
// last-match-wins semantics and the #771 tests above one layer up.
//
// getCoreExcludesFilePath/getGitInfoExcludePath are mocked here (see the
// vi.mock + file-wide beforeEach above) — their own real-git behavior is
// covered in git.test.ts. This block only proves loadIgnoreRules wires
// them into the ignore instance with the right precedence and bypasses.
describe('loadIgnoreRules — git-native global ignore sources (#2606)', () => {
let repoDir: string;
let coreExcludesPath: string;
let infoExcludePath: string;
let originalNoGlobalIgnore: string | undefined;
beforeEach(async () => {
repoDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-global-ignore-repo-'));
coreExcludesPath = path.join(
await fs.mkdtemp(path.join(os.tmpdir(), 'gn-core-excludes-')),
'ignore',
);
infoExcludePath = path.join(
await fs.mkdtemp(path.join(os.tmpdir(), 'gn-info-exclude-')),
'exclude',
);
vi.mocked(git.getCoreExcludesFilePath).mockReturnValue(coreExcludesPath);
vi.mocked(git.getGitInfoExcludePath).mockReturnValue(infoExcludePath);
originalNoGlobalIgnore = process.env.GITNEXUS_NO_GLOBAL_IGNORE;
});
afterEach(async () => {
if (originalNoGlobalIgnore === undefined) {
delete process.env.GITNEXUS_NO_GLOBAL_IGNORE;
} else {
process.env.GITNEXUS_NO_GLOBAL_IGNORE = originalNoGlobalIgnore;
}
await fs.rm(repoDir, { recursive: true, force: true });
await fs.rm(path.dirname(coreExcludesPath), { recursive: true, force: true });
await fs.rm(path.dirname(infoExcludePath), { recursive: true, force: true });
});
it('honours rules from core.excludesFile when no per-repo files exist', async () => {
await fs.writeFile(coreExcludesPath, 'docs/\n');
const ig = await loadIgnoreRules(repoDir);
expect(ig).not.toBeNull();
expect(ig!.ignores('docs/guide.md')).toBe(true);
expect(ig!.ignores('src/index.ts')).toBe(false);
});
it('honours rules from $GIT_COMMON_DIR/info/exclude when no per-repo files exist', async () => {
await fs.writeFile(infoExcludePath, 'build/\n');
const ig = await loadIgnoreRules(repoDir);
expect(ig).not.toBeNull();
expect(ig!.ignores('build/out.js')).toBe(true);
expect(ig!.ignores('src/index.ts')).toBe(false);
});
it('info/exclude can negate a core.excludesFile rule (matches git precedence)', async () => {
await fs.writeFile(coreExcludesPath, 'docs/\n');
await fs.writeFile(infoExcludePath, '!docs/\n');
const ig = await loadIgnoreRules(repoDir);
expect(ig).not.toBeNull();
expect(ig!.ignores('docs/guide.md')).toBe(false);
});
it('per-repo .gitnexusignore can negate rules from both global sources', async () => {
await fs.writeFile(coreExcludesPath, 'docs/\n');
await fs.writeFile(infoExcludePath, 'build/\n');
await fs.writeFile(path.join(repoDir, '.gitnexusignore'), '!docs/\n!build/\n');
const ig = await loadIgnoreRules(repoDir);
expect(ig).not.toBeNull();
expect(ig!.ignores('docs/guide.md')).toBe(false);
expect(ig!.ignores('build/out.js')).toBe(false);
});
it('gracefully skips info/exclude when getGitInfoExcludePath returns null (not a git repo)', async () => {
vi.mocked(git.getGitInfoExcludePath).mockReturnValue(null);
await fs.writeFile(coreExcludesPath, 'docs/\n');
const ig = await loadIgnoreRules(repoDir);
expect(ig).not.toBeNull();
expect(ig!.ignores('docs/guide.md')).toBe(true);
});
it('GITNEXUS_NO_GLOBAL_IGNORE skips both global sources entirely', async () => {
await fs.writeFile(coreExcludesPath, 'docs/\n');
await fs.writeFile(infoExcludePath, 'build/\n');
process.env.GITNEXUS_NO_GLOBAL_IGNORE = '1';
const ig = await loadIgnoreRules(repoDir);
expect(ig).toBeNull();
});
it('noGlobalIgnore option skips both global sources entirely', async () => {
await fs.writeFile(coreExcludesPath, 'docs/\n');
await fs.writeFile(infoExcludePath, 'build/\n');
const ig = await loadIgnoreRules(repoDir, { noGlobalIgnore: true });
expect(ig).toBeNull();
});
it('missing files at both global source paths is a no-op (byte-identical to pre-#2606 behaviour)', async () => {
// coreExcludesPath/infoExcludePath point at real (empty) temp dirs, but
// neither file has been written, and no per-repo files exist either.
const ig = await loadIgnoreRules(repoDir);
expect(ig).toBeNull();
});
it('combines core.excludesFile, info/exclude, .gitignore, and .gitnexusignore together', async () => {
await fs.writeFile(coreExcludesPath, 'docs/\n');
await fs.writeFile(infoExcludePath, 'build/\n');
await fs.writeFile(path.join(repoDir, '.gitignore'), 'data/\n');
await fs.writeFile(path.join(repoDir, '.gitnexusignore'), 'vendor/\n');
const ig = await loadIgnoreRules(repoDir);
expect(ig).not.toBeNull();
expect(ig!.ignores('docs/guide.md')).toBe(true);
expect(ig!.ignores('build/out.js')).toBe(true);
expect(ig!.ignores('data/file.txt')).toBe(true);
expect(ig!.ignores('vendor/lib.js')).toBe(true);
expect(ig!.ignores('src/index.ts')).toBe(false);
});
// Root bypasses POSIX read-permission checks (see the analogous EACCES
// test above for .gitignore), so this can't reproduce under uid=0.
it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)(
'warns on an unreadable global source file but does not throw',
async () => {
await fs.writeFile(coreExcludesPath, 'docs/\n');
await fs.chmod(coreExcludesPath, 0o000);
const cap = _captureLogger();
const ig = await loadIgnoreRules(repoDir);
expect(ig).toBeNull();
expect(cap.records().some((r) => String(r.msg ?? '').includes(coreExcludesPath))).toBe(true);
cap.restore();
await fs.chmod(coreExcludesPath, 0o644);
},
);
});