diff --git a/gitnexus/src/config/ignore-service.ts b/gitnexus/src/config/ignore-service.ts index 02a26068e..66516d7d4 100644 --- a/gitnexus/src/config/ignore-service.ts +++ b/gitnexus/src/config/ignore-service.ts @@ -3,6 +3,7 @@ import fs from 'fs/promises'; import nodePath from 'path'; import type { Path } from 'path-scurry'; import { logger } from '../core/logger.js'; +import { getGlobalDir } from '../storage/repo-manager.js'; const DEFAULT_IGNORE_LIST = new Set([ // Version Control @@ -350,8 +351,18 @@ export const isHardcodedIgnoredDirectory = (name: string): boolean => { export interface IgnoreOptions { /** Skip .gitignore parsing, only read .gitnexusignore. Defaults to GITNEXUS_NO_GITIGNORE env var. */ noGitignore?: boolean; + /** Skip the user-level global ignore file. Defaults to GITNEXUS_NO_GLOBAL_IGNORE env var. */ + noGlobalIgnore?: boolean; } +/** + * Path to the user-level global ignore file, applied across every indexed + * repo (#2606). Same `.gitnexusignore` syntax; lives alongside + * `registry.json`/`config.json` under the existing global GitNexus + * directory (`GITNEXUS_HOME` or `~/.gitnexus`) rather than a new location. + */ +export const getGlobalIgnorePath = (): string => nodePath.join(getGlobalDir(), 'ignore'); + export const loadIgnoreRules = async ( repoPath: string, options?: IgnoreOptions, @@ -359,6 +370,23 @@ export const loadIgnoreRules = async ( const ig = ignore(); let hasRules = false; + // Global ignore file is added first so per-repo .gitignore/.gitnexusignore + // rules layer on top and can negate it, mirroring the existing + // .gitignore -> .gitnexusignore precedence below (#2606). + const skipGlobalIgnore = options?.noGlobalIgnore ?? !!process.env.GITNEXUS_NO_GLOBAL_IGNORE; + if (!skipGlobalIgnore) { + try { + const content = await fs.readFile(getGlobalIgnorePath(), 'utf-8'); + ig.add(content); + hasRules = true; + } catch (err: unknown) { + const code = (err as NodeJS.ErrnoException).code; + if (code !== 'ENOENT') { + logger.warn(` Warning: could not read global ignore file: ${(err as Error).message}`); + } + } + } + // Allow users to bypass .gitignore parsing (e.g. when .gitignore accidentally excludes source files) const skipGitignore = options?.noGitignore ?? !!process.env.GITNEXUS_NO_GITIGNORE; const filenames = skipGitignore ? ['.gitnexusignore'] : ['.gitignore', '.gitnexusignore']; diff --git a/gitnexus/test/unit/ignore-service.test.ts b/gitnexus/test/unit/ignore-service.test.ts index 9f989bbcc..639c9d78f 100644 --- a/gitnexus/test/unit/ignore-service.test.ts +++ b/gitnexus/test/unit/ignore-service.test.ts @@ -7,6 +7,7 @@ import { isHardcodedIgnoredDirectory, loadIgnoreRules, createIgnoreFilter, + getGlobalIgnorePath, } from '../../src/config/ignore-service.js'; import { _captureLogger } from '../../src/core/logger.js'; @@ -658,3 +659,116 @@ describe('loadIgnoreRules — GITNEXUS_NO_GITIGNORE env var', () => { } }); }); + +// ─── User-level global ignore file (#2606) ─────────────────────────── +// +// IgnoreService previously read only per-repo .gitignore/.gitnexusignore. +// #2606 asked for a global layer so an exclusion meant to apply to every +// indexed repo is declared once, under the existing GITNEXUS_HOME/~/.gitnexus +// global directory (same one that already holds registry.json/config.json), +// rather than being repeated per repo or hand-patched into node_modules. +// +// Precedence: the global file is added to the `ignore` instance BEFORE +// .gitignore/.gitnexusignore, so per-repo rules can negate it — the same +// last-add-wins mechanism the #771 tests above already lock in one layer up. +describe('loadIgnoreRules — user-level global ignore file (#2606)', () => { + let repoDir: string; + let globalHomeDir: string; + let originalGitnexusHome: string | undefined; + let originalNoGlobalIgnore: string | undefined; + + beforeEach(async () => { + repoDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-global-ignore-repo-')); + globalHomeDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-global-ignore-home-')); + originalGitnexusHome = process.env.GITNEXUS_HOME; + originalNoGlobalIgnore = process.env.GITNEXUS_NO_GLOBAL_IGNORE; + process.env.GITNEXUS_HOME = globalHomeDir; + }); + + afterEach(async () => { + if (originalGitnexusHome === undefined) { + delete process.env.GITNEXUS_HOME; + } else { + process.env.GITNEXUS_HOME = originalGitnexusHome; + } + 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(globalHomeDir, { recursive: true, force: true }); + }); + + it('getGlobalIgnorePath resolves under GITNEXUS_HOME', () => { + expect(getGlobalIgnorePath()).toBe(path.join(globalHomeDir, 'ignore')); + }); + + it('honours rules from the global ignore file when no per-repo files exist', async () => { + await fs.writeFile(getGlobalIgnorePath(), '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('per-repo .gitnexusignore can negate a global-ignore rule', async () => { + await fs.writeFile(getGlobalIgnorePath(), 'docs/\n'); + await fs.writeFile(path.join(repoDir, '.gitnexusignore'), '!docs/\n'); + const ig = await loadIgnoreRules(repoDir); + expect(ig).not.toBeNull(); + expect(ig!.ignores('docs/guide.md')).toBe(false); + }); + + it('GITNEXUS_NO_GLOBAL_IGNORE skips the global file entirely', async () => { + await fs.writeFile(getGlobalIgnorePath(), 'docs/\n'); + process.env.GITNEXUS_NO_GLOBAL_IGNORE = '1'; + const ig = await loadIgnoreRules(repoDir); + expect(ig).toBeNull(); + }); + + it('noGlobalIgnore option skips the global file entirely', async () => { + await fs.writeFile(getGlobalIgnorePath(), 'docs/\n'); + const ig = await loadIgnoreRules(repoDir, { noGlobalIgnore: true }); + expect(ig).toBeNull(); + }); + + it('missing global ignore file is a no-op (byte-identical to pre-#2606 behaviour)', async () => { + // globalHomeDir exists but has no `ignore` file in it, and no per-repo files either. + const ig = await loadIgnoreRules(repoDir); + expect(ig).toBeNull(); + }); + + it('still combines global, .gitignore, and .gitnexusignore rules together', async () => { + await fs.writeFile(getGlobalIgnorePath(), 'docs/\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('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 ignore file but does not throw', + async () => { + const globalIgnorePath = getGlobalIgnorePath(); + await fs.writeFile(globalIgnorePath, 'docs/\n'); + await fs.chmod(globalIgnorePath, 0o000); + + const cap = _captureLogger(); + const ig = await loadIgnoreRules(repoDir); + expect(ig).toBeNull(); + expect( + cap.records().some((r) => String(r.msg ?? '').includes('global ignore file')), + ).toBe(true); + + cap.restore(); + await fs.chmod(globalIgnorePath, 0o644); + }, + ); +});