From 322e05a6be0fc49f4d938332689ec9b41475bab1 Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Tue, 21 Jul 2026 16:58:07 +0000 Subject: [PATCH 1/5] 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. --- gitnexus/src/config/ignore-service.ts | 28 ++++++ gitnexus/test/unit/ignore-service.test.ts | 114 ++++++++++++++++++++++ 2 files changed, 142 insertions(+) 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); + }, + ); +}); From 5893de1194a5a25ca557d165e05228caee70f4e2 Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Tue, 21 Jul 2026 16:58:57 +0000 Subject: [PATCH 2/5] docs(readme): document the global ignore file (#2606) --- gitnexus/README.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/gitnexus/README.md b/gitnexus/README.md index 619872b8f..11535947c 100644 --- a/gitnexus/README.md +++ b/gitnexus/README.md @@ -511,9 +511,16 @@ For very large repositories: # Increase Node.js heap size NODE_OPTIONS="--max-old-space-size=16384" npx gitnexus analyze -# Exclude large directories +# Exclude large directories (this repo only) echo "vendor/" >> .gitnexusignore echo "dist/" >> .gitnexusignore + +# Exclude a directory across every repo you index, without touching each +# repo's own .gitnexusignore. Same syntax; lives next to registry.json and +# config.json under the global GitNexus directory ($GITNEXUS_HOME, default +# ~/.gitnexus). A repo's own .gitignore/.gitnexusignore can still override +# it with a `!pattern` negation. Skip it entirely with GITNEXUS_NO_GLOBAL_IGNORE=1. +mkdir -p ~/.gitnexus && echo "docs/" >> ~/.gitnexus/ignore ``` ### Large files are being skipped From a4a79ac9200fdba480e58beaf1f0d343cc456e10 Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Tue, 21 Jul 2026 17:40:31 +0000 Subject: [PATCH 3/5] style: fix prettier formatting in ignore-service.test.ts --- gitnexus/test/unit/ignore-service.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/gitnexus/test/unit/ignore-service.test.ts b/gitnexus/test/unit/ignore-service.test.ts index 639c9d78f..1a4abf214 100644 --- a/gitnexus/test/unit/ignore-service.test.ts +++ b/gitnexus/test/unit/ignore-service.test.ts @@ -763,9 +763,9 @@ describe('loadIgnoreRules — user-level global ignore file (#2606)', () => { 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); + expect(cap.records().some((r) => String(r.msg ?? '').includes('global ignore file'))).toBe( + true, + ); cap.restore(); await fs.chmod(globalIgnorePath, 0o644); From 0f016dc467bdac155a8c7d2b8ce05c35729f191f Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Tue, 21 Jul 2026 18:06:22 +0000 Subject: [PATCH 4/5] fix(config): read core.excludesFile and .git/info/exclude for global ignores (#2606) Replace the custom ~/.gitnexus/ignore file with the same two sources real git itself consults for exactly this purpose (gitignore(5)): - core.excludesFile: git's own all-repos global ignore file (defaults to $XDG_CONFIG_HOME/git/ignore when unconfigured) - $GIT_COMMON_DIR/info/exclude: per-repo, untracked, so it works without push/commit access to the repo Precedence mirrors git exactly (lowest to highest): core.excludesFile, then info/exclude, then .gitignore, then .gitnexusignore -- each later source can negate an earlier one via a `!pattern` line, same last-match-wins semantics git itself uses. Adds getCoreExcludesFilePath and getGitInfoExcludePath to git.ts, following the same execSync + git-common-dir pattern as getCanonicalRepoRoot. GITNEXUS_NO_GLOBAL_IGNORE (or noGlobalIgnore) still skips both global sources, mirroring GITNEXUS_NO_GITIGNORE. --- gitnexus/README.md | 13 +- gitnexus/src/config/ignore-service.ts | 43 +++--- gitnexus/src/storage/git.ts | 54 ++++++++ gitnexus/test/unit/git.test.ts | 76 ++++++++++- gitnexus/test/unit/ignore-service.test.ts | 152 +++++++++++++++------- 5 files changed, 264 insertions(+), 74 deletions(-) diff --git a/gitnexus/README.md b/gitnexus/README.md index 11535947c..edee4da57 100644 --- a/gitnexus/README.md +++ b/gitnexus/README.md @@ -516,11 +516,14 @@ echo "vendor/" >> .gitnexusignore echo "dist/" >> .gitnexusignore # Exclude a directory across every repo you index, without touching each -# repo's own .gitnexusignore. Same syntax; lives next to registry.json and -# config.json under the global GitNexus directory ($GITNEXUS_HOME, default -# ~/.gitnexus). A repo's own .gitignore/.gitnexusignore can still override -# it with a `!pattern` negation. Skip it entirely with GITNEXUS_NO_GLOBAL_IGNORE=1. -mkdir -p ~/.gitnexus && echo "docs/" >> ~/.gitnexus/ignore +# repo's own .gitnexusignore or needing push/commit access to it. GitNexus +# reads the same sources `git` itself does: core.excludesFile (all repos) +# and $GIT_DIR/info/exclude (this repo only, untracked). A repo's own +# .gitignore/.gitnexusignore can still override either with a `!pattern` +# negation. Skip both entirely with GITNEXUS_NO_GLOBAL_IGNORE=1. +git config --global core.excludesFile ~/.gitignore_global # applies to every repo +echo "docs/" >> ~/.gitignore_global +echo "build/" >> .git/info/exclude # this repo only, untracked ``` ### Large files are being skipped diff --git a/gitnexus/src/config/ignore-service.ts b/gitnexus/src/config/ignore-service.ts index 66516d7d4..a5d0e05bb 100644 --- a/gitnexus/src/config/ignore-service.ts +++ b/gitnexus/src/config/ignore-service.ts @@ -3,7 +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'; +import { getCoreExcludesFilePath, getGitInfoExcludePath } from '../storage/git.js'; const DEFAULT_IGNORE_LIST = new Set([ // Version Control @@ -351,18 +351,10 @@ 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. */ + /** Skip core.excludesFile and $GIT_COMMON_DIR/info/exclude. 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, @@ -370,19 +362,28 @@ 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). + // Mirror git's own precedence for ignore sources (gitignore(5)): patterns + // from core.excludesFile are consulted first (lowest precedence — git's + // real global, all-repos file), then $GIT_COMMON_DIR/info/exclude + // (per-repo, untracked — no write access to the repo needed), then + // .gitignore/.gitnexusignore below. Later ig.add() calls win on + // conflicting patterns, matching git's own last-match-wins semantics (#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}`); + const globalSources = [ + getCoreExcludesFilePath(repoPath), + getGitInfoExcludePath(repoPath), + ].filter((candidate): candidate is string => candidate !== null); + for (const sourcePath of globalSources) { + try { + const content = await fs.readFile(sourcePath, '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 ${sourcePath}: ${(err as Error).message}`); + } } } } diff --git a/gitnexus/src/storage/git.ts b/gitnexus/src/storage/git.ts index 7a30ba505..898211f5c 100644 --- a/gitnexus/src/storage/git.ts +++ b/gitnexus/src/storage/git.ts @@ -1,6 +1,7 @@ import { execFileSync, execSync } from 'child_process'; import { statSync } from 'fs'; import path from 'path'; +import os from 'os'; // Git utilities for repository detection, commit tracking, and diff analysis @@ -209,6 +210,59 @@ export const getCanonicalRepoRoot = (fromPath: string): string | null => { } }; +/** + * Path to the repo's `$GIT_COMMON_DIR/info/exclude` file — git's own + * per-repo, untracked exclude list (same tier as `.gitignore` in + * precedence, but never committed, so it works even when the caller has + * no write access to the repo's tracked content). Shared across every + * linked worktree of a repo, matching git's own resolution (#2606). + * + * Returns `null` when `fromPath` is not inside a git repository or `git` + * is unavailable; callers should treat that the same as "no file". + */ +export const getGitInfoExcludePath = (fromPath: string): string | null => { + try { + const commonDir = chompGitOutput( + execSync('git rev-parse --path-format=absolute --git-common-dir', { + cwd: fromPath, + stdio: ['ignore', 'pipe', 'ignore'], + windowsHide: true, + }), + ); + if (!commonDir) return null; + return path.join(path.resolve(commonDir), 'info', 'exclude'); + } catch { + return null; + } +}; + +/** + * Path to git's own global, all-repos ignore file: the value of + * `core.excludesFile` (any config scope — system/global/local, resolved + * the same way `git` itself would from `fromPath`), or git's documented + * default of `$XDG_CONFIG_HOME/git/ignore` when unset (gitignore(5)). + * Lowest-precedence source, mirroring git's own behavior (#2606). + * + * Never throws: an unset key or unavailable `git` falls through to the + * default path, which is always computable without `git`. + */ +export const getCoreExcludesFilePath = (fromPath: string): string => { + try { + const configured = chompGitOutput( + execSync('git config --get --type=path core.excludesFile', { + cwd: fromPath, + stdio: ['ignore', 'pipe', 'ignore'], + windowsHide: true, + }), + ); + if (configured) return configured; + } catch { + // Unset, or git unavailable — fall through to git's documented default. + } + const xdgConfigHome = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'); + return path.join(xdgConfigHome, 'git', 'ignore'); +}; + /** * Resolve `fromPath` to the directory whose basename should drive the * registry name (#1259) — the *identity root*. Three outcomes: diff --git a/gitnexus/test/unit/git.test.ts b/gitnexus/test/unit/git.test.ts index cfbf7d74e..3acab2bba 100644 --- a/gitnexus/test/unit/git.test.ts +++ b/gitnexus/test/unit/git.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { execSync } from 'child_process'; import fs from 'fs'; import os from 'os'; @@ -12,6 +12,8 @@ import { sanitizeRepoName, getDefaultBranch, getCurrentBranch, + getGitInfoExcludePath, + getCoreExcludesFilePath, } from '../../src/storage/git.js'; // Mock child_process.execSync @@ -287,4 +289,76 @@ describe('git utilities', () => { expect(parseRepoNameFromUrl(null)).toBeNull(); }); }); + + describe('getGitInfoExcludePath (#2606)', () => { + it('joins info/exclude onto the absolute git-common-dir', () => { + mockExecSync.mockReturnValueOnce(Buffer.from('/repo/.git\n')); + expect(getGitInfoExcludePath('/repo')).toBe(path.join('/repo/.git', 'info', 'exclude')); + expect(mockExecSync).toHaveBeenCalledWith( + 'git rev-parse --path-format=absolute --git-common-dir', + expect.objectContaining({ cwd: '/repo', windowsHide: true }), + ); + }); + + it('resolves the worktree-shared common dir, not a per-worktree one', () => { + // $GIT_COMMON_DIR is the same for the main checkout and every linked + // worktree, so a worktree's info/exclude resolves to the shared main repo. + mockExecSync.mockReturnValueOnce(Buffer.from('/repo/.git\n')); + expect(getGitInfoExcludePath('/repo/.worktrees/feature')).toBe( + path.join('/repo/.git', 'info', 'exclude'), + ); + }); + + it('returns null when not inside a git repository', () => { + mockExecSync.mockImplementationOnce(() => { + throw new Error('not a git repo'); + }); + expect(getGitInfoExcludePath('/not-a-repo')).toBeNull(); + }); + }); + + describe('getCoreExcludesFilePath (#2606)', () => { + let originalXdgConfigHome: string | undefined; + + beforeEach(() => { + originalXdgConfigHome = process.env.XDG_CONFIG_HOME; + }); + + afterEach(() => { + if (originalXdgConfigHome === undefined) { + delete process.env.XDG_CONFIG_HOME; + } else { + process.env.XDG_CONFIG_HOME = originalXdgConfigHome; + } + }); + + it('returns the configured core.excludesFile value', () => { + mockExecSync.mockReturnValueOnce(Buffer.from('/home/user/.gitignore_global\n')); + expect(getCoreExcludesFilePath('/repo')).toBe('/home/user/.gitignore_global'); + expect(mockExecSync).toHaveBeenCalledWith( + 'git config --get --type=path core.excludesFile', + expect.objectContaining({ cwd: '/repo', windowsHide: true }), + ); + }); + + it("falls back to git's documented default ($XDG_CONFIG_HOME/git/ignore) when unset", () => { + mockExecSync.mockImplementationOnce(() => { + throw new Error('key not set'); // git config --get exits 1 when unset + }); + process.env.XDG_CONFIG_HOME = '/home/user/.config'; + expect(getCoreExcludesFilePath('/repo')).toBe( + path.join('/home/user/.config', 'git', 'ignore'), + ); + }); + + it('falls back to the default even when git is unavailable entirely', () => { + mockExecSync.mockImplementationOnce(() => { + throw new Error('git: command not found'); + }); + process.env.XDG_CONFIG_HOME = '/home/user/.config'; + expect(getCoreExcludesFilePath('/anything')).toBe( + path.join('/home/user/.config', 'git', 'ignore'), + ); + }); + }); }); diff --git a/gitnexus/test/unit/ignore-service.test.ts b/gitnexus/test/unit/ignore-service.test.ts index 1a4abf214..23cb9abbc 100644 --- a/gitnexus/test/unit/ignore-service.test.ts +++ b/gitnexus/test/unit/ignore-service.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach } from 'vitest'; +import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach, vi } from 'vitest'; import fs from 'fs/promises'; import path from 'path'; import os from 'os'; @@ -7,9 +7,32 @@ import { isHardcodedIgnoredDirectory, loadIgnoreRules, createIgnoreFilter, - getGlobalIgnorePath, } 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', () => { @@ -660,92 +683,130 @@ describe('loadIgnoreRules — GITNEXUS_NO_GITIGNORE env var', () => { }); }); -// ─── User-level global ignore file (#2606) ─────────────────────────── +// ─── Git-native global ignore sources (#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. +// #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: 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)', () => { +// 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 globalHomeDir: string; - let originalGitnexusHome: string | undefined; + let coreExcludesPath: string; + let infoExcludePath: string; 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; + 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; - 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 }); + await fs.rm(path.dirname(coreExcludesPath), { recursive: true, force: true }); + await fs.rm(path.dirname(infoExcludePath), { 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'); + 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('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'); + 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('GITNEXUS_NO_GLOBAL_IGNORE skips the global file entirely', async () => { - await fs.writeFile(getGlobalIgnorePath(), 'docs/\n'); + 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 the global file entirely', async () => { - await fs.writeFile(getGlobalIgnorePath(), 'docs/\n'); + 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 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. + 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('still combines global, .gitignore, and .gitnexusignore rules together', async () => { - await fs.writeFile(getGlobalIgnorePath(), 'docs/\n'); + 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); @@ -754,21 +815,18 @@ describe('loadIgnoreRules — user-level global ignore file (#2606)', () => { // 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', + 'warns on an unreadable global source file but does not throw', async () => { - const globalIgnorePath = getGlobalIgnorePath(); - await fs.writeFile(globalIgnorePath, 'docs/\n'); - await fs.chmod(globalIgnorePath, 0o000); + 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('global ignore file'))).toBe( - true, - ); + expect(cap.records().some((r) => String(r.msg ?? '').includes(coreExcludesPath))).toBe(true); cap.restore(); - await fs.chmod(globalIgnorePath, 0o644); + await fs.chmod(coreExcludesPath, 0o644); }, ); }); From 382801790cf41b12775ca39ae58eb9fea48033db Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Tue, 21 Jul 2026 19:09:25 +0000 Subject: [PATCH 5/5] perf(config): memoize core.excludesFile / info/exclude resolution (#2606) loadIgnoreRules is called once per repo, per language/contract extractor during group sync -- an N-repo group fans out to 6+ extractors each calling it, turning an uncached execSync per call into O(extractors x repos) blocking subprocess spawns for the exact many-repos scenario #2606 describes. Both getGitInfoExcludePath and getCoreExcludesFilePath resolve to the same value for the same fromPath for the life of the process, so memoize by fromPath in a process-lifetime Map. One-shot CLI runs are unaffected by staleness; the long-lived MCP server would need explicit invalidation if this becomes a real concern. --- gitnexus/src/storage/git.ts | 37 ++++++++++++++++++++++----- gitnexus/test/unit/git.test.ts | 46 +++++++++++++++++++++++++++++++++- 2 files changed, 76 insertions(+), 7 deletions(-) diff --git a/gitnexus/src/storage/git.ts b/gitnexus/src/storage/git.ts index 898211f5c..4b4fdd0f1 100644 --- a/gitnexus/src/storage/git.ts +++ b/gitnexus/src/storage/git.ts @@ -210,6 +210,18 @@ export const getCanonicalRepoRoot = (fromPath: string): string | null => { } }; +// getGitInfoExcludePath/getCoreExcludesFilePath are called once per repo +// PER language/contract extractor during group sync (#2606) — an N-repo +// group fans out to 6+ extractors each calling these, so an uncached +// execSync per call turns into O(extractors × repos) blocking subprocess +// spawns. Both resolve to the same value for the same fromPath for the +// life of the process (git config/exclude files don't change mid-run), so +// memoize by fromPath. ponytail: process-lifetime cache, never invalidated +// — fine for one-shot CLI runs; the long-lived MCP server would need a +// TTL or explicit invalidation if a user edits core.excludesFile mid-session. +const gitInfoExcludePathCache = new Map(); +const coreExcludesFilePathCache = new Map(); + /** * Path to the repo's `$GIT_COMMON_DIR/info/exclude` file — git's own * per-repo, untracked exclude list (same tier as `.gitignore` in @@ -221,6 +233,10 @@ export const getCanonicalRepoRoot = (fromPath: string): string | null => { * is unavailable; callers should treat that the same as "no file". */ export const getGitInfoExcludePath = (fromPath: string): string | null => { + const cached = gitInfoExcludePathCache.get(fromPath); + if (cached !== undefined) return cached; + + let result: string | null; try { const commonDir = chompGitOutput( execSync('git rev-parse --path-format=absolute --git-common-dir', { @@ -229,11 +245,12 @@ export const getGitInfoExcludePath = (fromPath: string): string | null => { windowsHide: true, }), ); - if (!commonDir) return null; - return path.join(path.resolve(commonDir), 'info', 'exclude'); + result = commonDir ? path.join(path.resolve(commonDir), 'info', 'exclude') : null; } catch { - return null; + result = null; } + gitInfoExcludePathCache.set(fromPath, result); + return result; }; /** @@ -247,6 +264,10 @@ export const getGitInfoExcludePath = (fromPath: string): string | null => { * default path, which is always computable without `git`. */ export const getCoreExcludesFilePath = (fromPath: string): string => { + const cached = coreExcludesFilePathCache.get(fromPath); + if (cached !== undefined) return cached; + + let result: string | undefined; try { const configured = chompGitOutput( execSync('git config --get --type=path core.excludesFile', { @@ -255,12 +276,16 @@ export const getCoreExcludesFilePath = (fromPath: string): string => { windowsHide: true, }), ); - if (configured) return configured; + if (configured) result = configured; } catch { // Unset, or git unavailable — fall through to git's documented default. } - const xdgConfigHome = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'); - return path.join(xdgConfigHome, 'git', 'ignore'); + if (!result) { + const xdgConfigHome = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'); + result = path.join(xdgConfigHome, 'git', 'ignore'); + } + coreExcludesFilePathCache.set(fromPath, result); + return result; }; /** diff --git a/gitnexus/test/unit/git.test.ts b/gitnexus/test/unit/git.test.ts index 3acab2bba..b99a75566 100644 --- a/gitnexus/test/unit/git.test.ts +++ b/gitnexus/test/unit/git.test.ts @@ -346,7 +346,10 @@ describe('git utilities', () => { throw new Error('key not set'); // git config --get exits 1 when unset }); process.env.XDG_CONFIG_HOME = '/home/user/.config'; - expect(getCoreExcludesFilePath('/repo')).toBe( + // Different fromPath than the "configured" test above — each function + // caches by fromPath (see below), so reusing '/repo' here would return + // that test's cached result instead of exercising the fallback. + expect(getCoreExcludesFilePath('/repo-unconfigured')).toBe( path.join('/home/user/.config', 'git', 'ignore'), ); }); @@ -361,4 +364,45 @@ describe('git utilities', () => { ); }); }); + + // A group sync calls loadIgnoreRules (and therefore these two functions) + // once per repo, per extractor — repeated calls with the same fromPath + // are the normal case, not an edge case. Both functions memoize by + // fromPath so a second call never spawns a second subprocess (#2606). + describe('getGitInfoExcludePath / getCoreExcludesFilePath caching (#2606)', () => { + it('getGitInfoExcludePath only spawns git once for repeated calls with the same fromPath', () => { + mockExecSync.mockReturnValueOnce(Buffer.from('/cached-repo/.git\n')); + const first = getGitInfoExcludePath('/cached-repo'); + const second = getGitInfoExcludePath('/cached-repo'); + expect(first).toBe(path.join('/cached-repo/.git', 'info', 'exclude')); + expect(second).toBe(first); + expect(mockExecSync).toHaveBeenCalledTimes(1); + }); + + it('getGitInfoExcludePath caches a null result too (not-a-git-repo stays cheap)', () => { + mockExecSync.mockImplementationOnce(() => { + throw new Error('not a git repo'); + }); + expect(getGitInfoExcludePath('/cached-non-repo')).toBeNull(); + expect(getGitInfoExcludePath('/cached-non-repo')).toBeNull(); + expect(mockExecSync).toHaveBeenCalledTimes(1); + }); + + it('getCoreExcludesFilePath only spawns git once for repeated calls with the same fromPath', () => { + mockExecSync.mockReturnValueOnce(Buffer.from('/home/user/.gitignore_global\n')); + const first = getCoreExcludesFilePath('/cached-repo-2'); + const second = getCoreExcludesFilePath('/cached-repo-2'); + expect(first).toBe('/home/user/.gitignore_global'); + expect(second).toBe(first); + expect(mockExecSync).toHaveBeenCalledTimes(1); + }); + + it("a different fromPath is not served from another path's cache entry", () => { + mockExecSync.mockReturnValueOnce(Buffer.from('/repo-a/.git\n')); + mockExecSync.mockReturnValueOnce(Buffer.from('/repo-b/.git\n')); + expect(getGitInfoExcludePath('/repo-a')).toBe(path.join('/repo-a/.git', 'info', 'exclude')); + expect(getGitInfoExcludePath('/repo-b')).toBe(path.join('/repo-b/.git', 'info', 'exclude')); + expect(mockExecSync).toHaveBeenCalledTimes(2); + }); + }); });