GitNexus/gitnexus/test/unit/ignore-service.test.ts
DuduPhudu fb49613a4d
fix(ingestion): ignore emitted Next.js build output, and delete the inert public/build entry (#3018)
* fix(ingestion): ignore emitted Next.js build output, and restore the dead public/build entry

`DEFAULT_IGNORE_LIST` contained `.next` — the build CACHE — but not `_next`, the
emitted OUTPUT, which are different directories. A Capacitor/Cordova shell copies
a built Next.js bundle to `<platform>/app/src/main/assets/public/_next/static/`,
where no path segment hits the list, so the walker indexed the bundle as source.
On a real mobile-wrapped Next.js app that was 256 minified chunk files, and every
`Route` node the repo produced pointed at a webpack chunk rather than at source.

The filename heuristics did not catch them either: they match `.bundle.`,
`.chunk.`, `.generated.` and `.d.ts`, while Next.js emits hashed names like
`6862-9d1cdcb99f169a06.js`.

Separately, `'public/build'` had been sitting in `DEFAULT_IGNORE_LIST` matching
nothing at all. That set is tested one path SEGMENT at a time, and is also read
by `isHardcodedIgnoredDirectory(name)`, which receives a bare directory name —
so a slash-containing member can never compare equal to anything. Rather than
delete the entry and lose its intent, multi-segment paths now live in
`DEFAULT_IGNORED_PATH_FRAGMENTS` and are matched against the whole path, so
Remix / Laravel Mix asset output is ignored as originally intended.

A guard test pins the invariant that made the dead entry possible: no member of
the name set may contain a slash.

Measured against a production Capacitor-wrapped Next.js app (1558 JS/TS files on
disk): 256 newly ignored, none of them under `src/`, and zero files that were
previously ignored become indexed.

Closes #3007

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(ingestion): drop the inert public/build machinery, discriminate _next by segment, ignore _next on the web upload path

Addresses the review findings on #3018.

Remove DEFAULT_IGNORED_PATH_FRAGMENTS, hasIgnoredPathFragment and its
shouldIgnorePath branch. The mechanism was correct but unreachable: all four
of its match forms put a `/` or end-of-string on both sides of `build`, so a
fragment match strictly implies `build` is a whole segment, which the
per-segment DEFAULT_IGNORE_LIST loop already catches one branch earlier.
Measured over 768,420 generated paths: 65,506 fragment matches, 0 of them
decisive, 0 implication violations. `'public/build'` really was an inert
member of the name set, but its paths were never unignored — bare `'build'`
covered them on both sides — so the entry is deleted rather than relocated,
which is the other option #3007 offered. The slash-free guard test stays; it
is what stops the next slash-bearing entry from dying the same way.

Add negative cases pinning that `_next` matches as a whole path segment. The
previous suite could not tell a segment rule from a substring rule: replacing
the entry with `normalizedPath.includes('_next')` passed all five tests, while
eating `src/_nextgen/index.ts`.

Rename the public/build test to what it actually pins — that deleting the
inert entry changed no behavior — since it is green on both sides by design.

Add `_next` to the web upload filter's EXCLUDED_DIRS. That list is the live
browser ingestion path (RepoAnalyzer -> filterRepoFiles -> /api/analyze/upload)
and had `.next` but not `_next`, so a Capacitor-wrapped Next.js app uploaded
its entire minified tree against the server's 20000-file / 250MB caps for
files the analyzer then discards.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(ignore-service): make the single-component set guards able to fail

The slash-free guard added for #3007 could not fail. It selected entry lines
with startsWith("'") and read only the first quoted token per line, so
'public/build' could return as a backtick string, behind an inline block
comment, as a second entry on an existing line, or via .add() and every test
stayed green. Prettier and eslint miss the backtick and inline-comment forms
too, so CI did not catch them either.

U1: remove the duplicate '.serverless' entry so the set can be pinned to one
exact number. A Set discarded it, so no ignore behaviour changes.

U2/U3: replace the line-based parser with a shared single-pass scanner in
test/helpers/ignore-set-source.ts, and extend the guard from DEFAULT_IGNORE_LIST
to IGNORED_FILES, ROOT_ARTIFACT_DIRECTORIES and IGNORED_EXTENSIONS, which share
the same single-component match contract.

The scanner tracks string and comment state together because neither can be
removed first: the ignore-list comments quote paths and carry an apostrophe, so
matching literals before stripping comments yields phantom slash-bearing
entries; and a glob string containing a comment-open sequence makes regex
comment-stripping swallow the closing bracket. Only a single pass is correct in
both directions.

Counts are pinned exactly rather than floored — a floor cannot protect a
two-member set and hides a partial parse. Shapes a source parser cannot resolve
(spread, interpolation, concatenation, later .add) now throw instead of quietly
reporting fewer members, and the parsed names are cross-checked against
isHardcodedIgnoredDirectory so parser drift fails without exporting the set.

Verified by mutation: all six fail-open spellings now turn the suite red;
187 tests pass, tsc clean.

* test(ignore-service): pin that _next prunes the directory, not just its files

Every measured benefit of ignoring _next comes from never enumerating the
bundle tree, and no file list can observe that: anything under _next is
rejected whether the walk pruned the directory or descended and rejected each
file. childrenIgnored is the only observation that separates them.

The existing build-output tests all call shouldIgnorePath, the leaf predicate,
so a refactor moving _next to a shouldIgnorePath-only rule would keep them green
while silently restoring the full walk. These assertions close that.

Also pins that _next matches as a whole segment (_nextgen and my_next are still
walked), and that the `!_next/` negation recovers the directory at any depth —
the bare form is the one that works, since `!_next/**` alone never gets tested:
childrenIgnored prunes the directory before any descendant pattern is reached.

Placed in the .gitnexusignore-negation describe block, which owns mkPath and the
tmpdir fixture and is registered in scripts/cross-platform-tests.ts.

Verified by mutation: disabling only the pruning branch in childrenIgnored leaves
the build-output suite at 26/26 green and turns these assertions red.

* test(ignore-service): guard the twin build-output ignore lists against drift

_next now lives in two lists in two packages — the analyzer's DEFAULT_IGNORE_LIST
and the browser upload filter's EXCLUDED_DIRS — with nothing tying them
together. This is the seventh twin-list pair in this repo; the header of
receiver-twin-list-drift.test.ts records that the previous ones each shipped a
bug when one side moved.

Containment runs web -> CLI only, and that is the load-bearing direction: the
browser filter decides what the server ever sees, and it reads no
.gitnexusignore, so a name it drops that the analyzer would have indexed is
silent source loss with no recovery. The reverse is not an error — the analyzer
prunes far more aggressively than an upload needs to.

.gitnexus is the one exemption and has a mechanism: the walker passes
dot: false to glob, so it never enumerates dot-directories. Asserted in both
directions so re-adding it to the CLI list or dropping it from the web list
both fail.

Both sides are source-parsed through the shared helper. DEFAULT_IGNORE_LIST is
module-private, and no test in this package imports across the package boundary
— every cross-package precedent reads source instead.

Also corrects the documentation this PR's comments got wrong: the guard test is
cited by path rather than as "below", the unreproducible per-repo percentage is
gone, the reason _next is deliberately unanchored is recorded next to the entry
(no <web-root>/_next form matches a root-level _next/static/…), and the upload
filter now states that it consults no repository ignore rules — so unlike the
CLI, a negation cannot recover what it drops.

Verified by mutation: a web-only addition and a CLI removal each turn the guard
red. 194 targeted tests pass; tsc clean in both packages.

* refactor(test): read the ignore sets with the TypeScript parser, not a hand-rolled scanner

The guards read ignore-service.ts as source because the sets are module-private.
The first pass hand-rolled a character scanner to do it, and the repo already
vendors the right tool: ts.createSourceFile, used this way in literal-collectors,
query-determinism-guard, cli-index-help and group/sync-partial-extraction.

The scanner had two silent gaps a real parser does not have:

- It rejected `${` by substring, but template literals were consumed whole, so
  that branch could never fire and an interpolated member was accepted as a
  literal — the exact under-report the file refused to allow.
- It took the first `[` after the marker, which on a type-annotated declaration
  (`readonly string[] = ...`) is the annotation's empty pair. It returned [] with
  no throw, which would make every assertion in a suite vacuously true. This is
  the hazard receiver-twin-list-drift.test.ts documents having hit.

Reading the declaration node removes both, along with the comment-vs-string
ordering problem that motivated the scanner: a parser cannot mistake a comment
for a string or a glob's `/*` for a comment-open.

Also drops the four pinned exact counts. They were a ratchet — these sets are
edited by unrelated PRs, each of which would have failed a count assertion about
nothing it touched — and with a real parser the partial-parse hazard they existed
to catch cannot happen silently: a member that is not a plain string literal
throws.

Markers collapse to set names, and the duplicated path-resolution boilerplate
moves into the helper the two suites already share.

Net 187 deletions against 123 insertions. Verified by mutation: backtick,
inline comment, same-line, double-quote, duplicate, interpolation, spread and
runtime .add() are all caught; a type-annotated declaration now reads correctly
instead of returning empty. 194 tests pass, tsc clean.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
2026-08-27 13:29:05 +01:00

923 lines
36 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 {
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('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);
},
);
});