GitNexus/gitnexus/test/unit/ignore-service.test.ts
WENJIE HUANG 32b5c0e3fc
feat: add IncludeExtractor for C++ cross-repo include tracking (group) (#1156)
* feat: add IncludeExtractor for C++ cross-repo include tracking (group)

* fix: address CodeQL warnings on include-extractor

- Remove unused HEADER_GLOB constant in include-extractor.ts
- Use fs.mkdtempSync for secure temp dir creation in tests
  (CodeQL: 'Insecure temporary file')

* fix(group): close missing ); in manifest-extractor include branch

The 'include' branch in ManifestExtractor.resolveSymbol was missing
the closing ); for the executor() call, causing a syntax error that
broke ESLint, Prettier, and the full test CI on all platforms.

Reported by Claude PR review on #1156.

* chore: drop test/global-setup.ts + test/vitest.d.ts

Upstream removed these in commit 3f0c74fe (ladybugdb 0.16.0 upgrade).
Commit 3f5d21c5 accidentally restored them during a rebase dance.

* style(group): reformat VALID_CONTRACT_TYPES array to satisfy prettier

Adding 'include' pushed the array over prettier's 100-char limit,
so prettier prefers multi-line. Apply the reformat to unbreak
ci-quality/format job.

* fix(include-extractor): address PR #1156 Claude review findings #3-#7

Claude Deep Review raised 7 findings on the IncludeExtractor. #1/#2
(BLOCKERs) were fixed earlier. This commit closes the remaining five.

#3 HIGH  case-sensitive FS -> provider contract-id collision
  Document the deliberate case-folding trade-off on normalizeIncludePath
  (matches C/C++ convention on Windows/macOS; collapses Foo.h & foo.h on
  Linux). Add a unit test pinning the behavior.

#4 HIGH  suffixResolve short-suffix match silently drops cross-repo include
  When a local file ends with the same basename as an external include
  (e.g. local internal/api.h vs. #include "ext/api.h"), suffixResolve
  returned a bogus local hit and suppressed the cross-repo consumer.
  Replace the suffixResolve lookup inside include-extractor with a
  strict isLocalInclude() that only accepts full-path hits via
  SuffixIndex.get / getInsensitive. Callers of suffixResolve elsewhere
  are unaffected. Add 3 unit tests covering the regression.

#5 MEDIUM regex fallback matched #include inside /* ... */
  Strip block comments before running the fallback regex scan.
  Add a unit test.

#6 MEDIUM meta.source was hard-coded to 'tree_sitter'
  Track the actual extraction path with an extractionSource local and
  write it into meta.source so downstream audits can distinguish
  tree-sitter parses from regex fallbacks. Add 2 unit tests.

#7 MEDIUM missing end-to-end coverage
  Add test/integration/group/include-extractor-sync.test.ts with 3
  cases exercising extractor -> syncGroup -> CrossLink (mocked
  contracts, mixed-case/backslash normalization, real temp repos).

Tests: 21 unit + 3 integration, all green.

* fix(lbug): robust Windows lock acquisition for CI integration tests

LadybugDB's `new Database()` raises `Could not set lock on file` from
local_file_system.cpp synchronously inside the constructor — before any
query is issued, so `withLbugDb`'s query-time retry never sees it. On
Windows CI this surfaces as flaky integration tests due to AV-scanner
holds, libuv handle-release lag, and stale `.wal` sidecars from aborted
prior runs.

This change closes the gap at *open time*:

- `openLbugConnection` now wraps `new lbug.Database()` in a bounded
  busy-retry (5x100ms back-off) inside `lbug-config.ts`. Errors that
  exhaust the budget are tagged via `LBUG_OPEN_RETRY_EXHAUSTED` so
  `withLbugDb`'s outer 3x retry skips re-retrying a freshly-exhausted
  path (eliminates the 3x5=15-attempt / ~6s tail latency).
- For recognized test fixtures only (immediate-parent dir matches a
  known prefix AND resolves under `os.tmpdir()`), one final stale-
  sidecar sweep removes `.wal`/`.lock` and retries once. Production
  paths never enter this branch.
- `safeClose` on Windows runs a bounded `fs.open` probe to absorb
  native handle-release lag; logs a warning if the probe exhausts so
  operators can spot AV interference.
- `isDbBusyError` is now defined in `lbug-config.ts` as the single
  source of truth, re-exported from `lbug-adapter.ts` for compatibility.
- New tests cover open-time retry (happy/retry/exhaust/non-busy/tag),
  stale-sidecar sweep (test-fixture-only, production-rejection,
  preserves-original-error), `isTestFixturePath` direct unit suite
  (accept/reject/traversal/nested/trailing-sep), and
  `waitForWindowsHandleRelease` (openable/ENOENT/no-leak).
- The two new test files are added to vitest's existing serialized
  `lbug-db` project (already `fileParallelism: false`).

Closes the chronic Windows CI flake on lbug-touching integration tests
while preserving the existing single-writable-Database-per-process
LadybugDB contract. No public API surface changed.

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

* refactor(lbug): drop isDbBusyError re-export, import from lbug-config directly

The re-export from lbug-adapter.ts was a transitional convenience — with
the matcher now living in lbug-config.ts, having two import paths for the
same symbol invites future drift. Updated the two real consumers
(lbug-lock-retry.test.ts, lbug-open-retry.test.ts) to import from
lbug-config directly, removed the re-export equality test (now vacuous),
and refreshed the explanatory comment so it no longer references a
re-export pattern that doesn't exist.

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

* fix(lbug): silence benign LadybugDB v0.16.1 schema-init lock warnings on Windows

doInitLbug logs "⚠️ Schema creation warning: ... Could not set lock on
file" on every CREATE NODE TABLE call after the first init on a given
dbPath, on Windows. The lock is internal to LadybugDB v0.16.1 and is
resolved before the table is created — same tolerance pattern as the
existing "already exists" filter. Genuine cross-process lock contention
still surfaces on the next operation through withLbugDb's retry, so
filtering at the schema-init catch only suppresses noise, not signal.

Also extend the safeClose Windows handle-release probe to cover the
.wal sidecar (the previous Database's WAL handle was the slowest to
release, surfacing as the schema-query lock contention) and switch the
probe back to 'r+' so it actually detects exclusive locks.

Test loop in lbug-close-handle-release.test.ts simplified to 10 plain
iterations now that the underlying noise is filtered upstream.

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

* chore(lbug): isDbBusyError review fixes

- Drop redundant `could not set lock` term — already subsumed by `lock`.
- Document the intentionally-broad matcher: graph-DB lock-shaped errors
  ("deadlock", "unlock failed", "lock contention", "could not open lock
  file") are all treated as transient. If a non-transient surfaces,
  tighten the matcher rather than raise the retry budget.
- Add positive test cases covering those lock-shaped strings so the
  intent is visible and a future tightening would deliberately break
  these.
- Fix the open-retry back-off comment: max sleep is 100+200+300+400 =
  1000ms (no sleep after the final attempt), not 1.5s.

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

* fix(group): address PR #1156 follow-up review findings

Addresses two blockers and two mediums from the deep review.

BLOCKER 1: Windows CI ENOTEMPTY in sync.test.ts
  After this PR added writeBridge() to syncGroup, the existing test
  "writes registry to groupDir when skipWrite is false" fails on
  windows-latest. LadybugDB's checkpoint thread briefly outlives
  closeBridgeDb, holding a Win32 lock on bridge.lbug; the test's
  fs.rmSync then fails with ENOTEMPTY. Switched the test cleanup to
  cleanupTempDir from test/helpers/test-db.ts which already tolerates
  EBUSY/EPERM/EACCES/ENOTEMPTY with bounded retries — same pattern
  used elsewhere for LadybugDB-touching tests.

BLOCKER 2: Graph provider absolute-path bug
  extractProvidersGraph queried File.filePath from the LadybugDB graph
  but never stripped the repo root, so provider contract IDs ended up
  as include::/abs/path/foo.h while consumers emitted include::foo.h.
  These never matched through runExactMatch — silently producing 0
  cross-links for any indexed C++ repo (the primary use case).
  Now passes repoPath into extractProvidersGraph and applies
  path.relative(); rows that resolve outside repoPath (stale absolute
  paths from another machine, system headers somehow indexed) are
  dropped instead of polluting the registry.

MEDIUM: `../` relative includes produce spurious noise
  `#include "../foo.h"` is almost always intra-repo, but the suffix
  index can never match a `..`-prefixed path so it became a consumer
  contract no provider could satisfy. Now skipped before matching;
  covers both forward-slash and backslash forms.

MEDIUM: writeBridge error in sync.ts propagates uncaught
  contracts.json is the canonical source of truth and was just written
  successfully when writeBridge runs. A bridge-only failure (disk full,
  schema error, permission denied) shouldn't mask the registry. Wrapped
  writeBridge in try/catch with a logger.warn surfacing the path and
  recovery instructions.

Tests added:
  - extractProvidersGraph repo-relative ID generation (stub Cypher
    executor returns absolute paths)
  - extractProvidersGraph drops rows whose path resolves outside repo
  - `../foo.h` forward-slash skip
  - `..\foo.h` backslash-form skip

Skipped findings:
  - canExtract() removal (#5, low): canExtract is part of the
    ContractExtractor interface; every other extractor implements the
    same `return true` shape. Removing it from IncludeExtractor would
    break the interface contract — keeping for consistency.

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

* fix(group): close PR #1156 Codex adversarial findings

Two HIGH findings from the Codex adversarial review on
feat/group-include-extractor:

1. Default-on extraction silently changes existing groups (BLOCKER)
   DEFAULT_DETECT.includes was true, so any pre-existing group.yaml
   that omits the new field would gain a wave of include::* contracts
   on the next sync after upgrade. Flipped to false (opt-in). The
   integration test already declares includes: true explicitly so it
   survives unchanged; the unit extractor tests bypass parseGroupConfig
   entirely; the sync test uses extractorOverride. Only config-parser
   needed regression tests covering omitted/explicit/false variants.

2. IncludeExtractor scans outside the indexed file universe (BLOCKER)
   The extractor was running glob('**/*', { ignore: STANDARD_IGNORES })
   twice with a hand-rolled 9-pattern list, no .gitignore/.gitnexusignore
   honoring, and no max-file-size cap. That meant File:<path> contracts
   could appear for files ingestion would never index, producing
   cross-links group impact cannot fan out to (silent false-negatives).
   Refactored to a single discoverIndexableFiles() helper that mirrors
   walkRepositoryPaths exactly: createIgnoreFilter + getMaxFileSizeBytes,
   one discovery pass shared by provider and consumer paths. Dropped
   STANDARD_IGNORES and SOURCE_GLOB entirely.

   third_party and 3rdparty (the C/C++ vendored-deps conventions) were
   in the local ignore list but not in the canonical DEFAULT_IGNORE_LIST
   used by ingestion. Folded both into the canonical set rather than
   keep a parallel list — the whole point of the Codex finding is that
   two file-discovery implementations drift. Single source of truth.

Tests: 5 new regression tests for the discovery alignment (.gitignore,
.gitnexusignore, max-file-size on both provider and consumer paths)
plus 4 for the opt-in default. All 30 include-extractor tests + the
494-test group suite + ignore-service tests pass.

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

* fix(review): apply autofix feedback

ce-code-review surfaced 6 safe_auto findings on commit a9936a9b:

- T1 (testing, P2): the sync.ts:174 gate was untested with includes:false.
  Added a sync-level test mirroring the existing thrift-off pattern at
  sync.test.ts:545, asserting zero include contracts when the gate is
  disabled in a real syncGroup call.

- T3 (testing, P3): third_party and 3rdparty entries in DEFAULT_IGNORE_LIST
  had no regression test. Added both to ignore-service.test.ts's
  dependency-directories it.each block.

- M1 (maintainability, P3): discoverIndexableFiles JSDoc lacked a
  fork-warning relative to walkRepositoryPaths. Added a MAINTENANCE
  note explaining why the duplication is tolerated and the contract
  the two implementations must keep.

- M2 (maintainability, P3): thrift-extractor still hand-rolls its
  ignore array with no signal that DEFAULT_IGNORE_LIST additions
  silently do not apply there. Added TODO(#1156-followup) comments
  above both call sites.

- M3 (maintainability, P3): SOURCE_EXTENSIONS duplicated the four
  HEADER_EXTENSIONS entries with no expressed subset relationship.
  Spread HEADER_EXTENSIONS into SOURCE_EXTENSIONS so future header-
  extension additions propagate.

- C1+T4 (correctness+testing, P3, cross-reviewer corroborated):
  discoverIndexableFiles swallowed all fs.stat errors silently,
  including EACCES/EMFILE/EIO. Narrowed the catch to ENOENT (the
  documented benign glob/stat race) and added a logger.warn for
  any other code so operators can spot permission/resource issues.

All 629 tests pass; typecheck + prettier clean.

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

* fix(group): use retryRename in writeContractRegistry to absorb Windows EPERM

`storage.ts:62` used raw `fsp.rename` for the contracts.json atomic swap.
On Windows, AV scanners and concurrent renames briefly hold the
destination handle between rename calls, surfacing as EPERM/EBUSY.
The `insecure-tempfile.test.ts > concurrent writes do not collide`
test was flaking with `EPERM: operation not permitted, rename` on
windows-latest CI.

`bridge-db.ts` already has a battle-tested `retryRename(src, dst, 3)`
helper used at six call sites for exactly this pattern. Reusing it
here keeps the Windows-rename policy single-source-of-truth across
the group package.

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

* fix(group): drop macro-style #include from consumer contracts

Tree-sitter's `(_) @import.source` wildcard matches the identifier node
of `#include PLATFORM_HEADER`, so the cleaned value `PLATFORM_HEADER`
slipped past the system-header / `..` filters and was emitted as a
permanently orphaned consumer contract (no file is named after a macro
identifier, so no provider can ever match). Add a shape guard that
skips cleaned values lacking both a path separator and an extension
dot, plus regression tests for single and multi-macro files.

Also document `IncludeExtractor.canExtract()` as unused by sync.ts
(gated via `config.detect.includes` instead) and kept solely for
ContractExtractor interface uniformity.

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

---------

Co-authored-by: HuangWenjie <zhoudeng.hwj@alibaba-inc.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 09:31:59 +01:00

641 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 TypeScript declaration files', () => {
expect(shouldIgnorePath('types/index.d.ts')).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('__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('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'));
}
});
});