mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-03 02:21:44 +00:00
* 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>
923 lines
36 KiB
TypeScript
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);
|
|
},
|
|
);
|
|
});
|