fix(config): add user-level global ignore file (#2606)

IgnoreService only read per-repo .gitignore/.gitnexusignore, so an
exclusion meant to apply across every indexed repo had to be repeated
per repo or hand-patched into node_modules (wiped on every upgrade).

loadIgnoreRules now also reads a global ignore file at
$GITNEXUS_HOME/ignore (default ~/.gitnexus/ignore), reusing the
existing global directory that already holds registry.json and
config.json. It is added first, so per-repo .gitignore/.gitnexusignore
rules can still negate it, mirroring the .gitignore -> .gitnexusignore
precedence already in place. GITNEXUS_NO_GLOBAL_IGNORE (or
noGlobalIgnore) skips it, mirroring GITNEXUS_NO_GITIGNORE.
This commit is contained in:
Gergo Magyar 2026-07-21 16:58:07 +00:00
parent 3a8b369171
commit 322e05a6be
2 changed files with 142 additions and 0 deletions

View file

@ -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'];

View file

@ -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);
},
);
});