mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-08 03:08:13 +00:00
* fix(analyze): avoid native aborts on generated worker bundles Retire timed-out parse workers instead of force-terminating native parser state, and skip Monaco generated worker bundles by default while preserving explicit .gitnexusignore negation overrides. Constraint: Node native tree-sitter bindings can abort the process when a timed-out worker is terminated while inside parser state. Rejected: Falling back to sequential parsing for native stalls | it can move the same native crash onto the main thread. Confidence: high Scope-risk: moderate Directive: Keep timeout recovery from force-terminating workers until they return to JS or exit naturally. Tested: npm test; npx tsc --noEmit; npm run build; targeted analyze on /Users/wangxc/Code/keep; gitnexus detect_changes --scope staged Not-tested: Node 22 LTS runtime and non-macOS platforms * fix(worker): bound retired parser worker lifetimes Keep timeout recovery from immediately terminating workers that may still be inside native parser state, while making terminal pool shutdown own retired worker cleanup so long-lived processes do not accumulate retired threads. Constraint: Claude review on PR #1833 required retiredWorkers cleanup in pool.terminate() and tripBreaker() without regressing no-immediate-terminate timeout safety. Rejected: clearing the retiredWorkers set without terminating | would remove JS bookkeeping while leaking the underlying worker thread. Confidence: high Scope-risk: moderate Directive: Preserve the distinction between recoverable timeout retirement and terminal pool shutdown; do not reintroduce immediate terminate in removeWorkerFromSlot(..., 'retire'). Tested: npx vitest run test/unit/worker-pool-timeout-retire.test.ts; npx vitest run test/unit/worker-pool-timeout-retire.test.ts test/unit/worker-pool-resilience.test.ts test/unit/worker-pool-cumulative-timeout.test.ts test/unit/worker-pool-slot-generation.test.ts; npx tsc --noEmit; npm run build; npx prettier --check src/core/ingestion/workers/worker-pool.ts test/unit/worker-pool-timeout-retire.test.ts ../docs/todo/pr-1833-retired-worker-cleanup-plan.md; npx eslint src/core/ingestion/workers/worker-pool.ts test/unit/worker-pool-timeout-retire.test.ts; gitnexus detect_changes --scope staged. Not-tested: npm test full suite did not complete green in this environment; two runs each had one unrelated test/unit/hooks.test.ts parseHookOutput null failure, and each failed hook test passed when rerun in isolation. * ci: retrigger checks Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: wangxc <wangxc_a_bj@si-tech.com.cn> Co-authored-by: Gergő Magyar <gergomagyar@icloud.com> Co-authored-by: Test <test@example.com> Co-authored-by: Cursor <cursoragent@cursor.com>
660 lines
24 KiB
TypeScript
660 lines
24 KiB
TypeScript
import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach } from 'vitest';
|
|
import fs from 'fs/promises';
|
|
import path from 'path';
|
|
import os from 'os';
|
|
import {
|
|
shouldIgnorePath,
|
|
isHardcodedIgnoredDirectory,
|
|
loadIgnoreRules,
|
|
createIgnoreFilter,
|
|
} from '../../src/config/ignore-service.js';
|
|
import { _captureLogger } from '../../src/core/logger.js';
|
|
|
|
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('ignores TypeScript declaration files', () => {
|
|
expect(shouldIgnorePath('types/index.d.ts')).toBe(true);
|
|
});
|
|
|
|
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',
|
|
'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);
|
|
});
|
|
});
|
|
});
|
|
|
|
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);
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|
|
|
|
// ─── .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('`!__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('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('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'));
|
|
}
|
|
});
|
|
});
|