diff --git a/gitnexus/README.md b/gitnexus/README.md index 619872b8f..edee4da57 100644 --- a/gitnexus/README.md +++ b/gitnexus/README.md @@ -511,9 +511,19 @@ 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 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 02a26068e..a5d0e05bb 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 { getCoreExcludesFilePath, getGitInfoExcludePath } from '../storage/git.js'; const DEFAULT_IGNORE_LIST = new Set([ // Version Control @@ -350,6 +351,8 @@ 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 core.excludesFile and $GIT_COMMON_DIR/info/exclude. Defaults to GITNEXUS_NO_GLOBAL_IGNORE env var. */ + noGlobalIgnore?: boolean; } export const loadIgnoreRules = async ( @@ -359,6 +362,32 @@ export const loadIgnoreRules = async ( const ig = ignore(); let hasRules = false; + // 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) { + 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}`); + } + } + } + } + // 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/src/storage/git.ts b/gitnexus/src/storage/git.ts index 7a30ba505..4b4fdd0f1 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,84 @@ 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 + * 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 => { + 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', { + cwd: fromPath, + stdio: ['ignore', 'pipe', 'ignore'], + windowsHide: true, + }), + ); + result = commonDir ? path.join(path.resolve(commonDir), 'info', 'exclude') : null; + } catch { + result = null; + } + gitInfoExcludePathCache.set(fromPath, result); + return result; +}; + +/** + * 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 => { + 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', { + cwd: fromPath, + stdio: ['ignore', 'pipe', 'ignore'], + windowsHide: true, + }), + ); + if (configured) result = configured; + } catch { + // Unset, or git unavailable — fall through to git's documented default. + } + 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; +}; + /** * 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..b99a75566 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,120 @@ 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'; + // 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'), + ); + }); + + 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'), + ); + }); + }); + + // 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); + }); + }); }); diff --git a/gitnexus/test/unit/ignore-service.test.ts b/gitnexus/test/unit/ignore-service.test.ts index 9f989bbcc..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'; @@ -9,6 +9,30 @@ import { 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', () => { @@ -658,3 +682,151 @@ describe('loadIgnoreRules — GITNEXUS_NO_GITIGNORE env var', () => { } }); }); + +// ─── 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); + }, + ); +});