Merge pull request #2613 from magyargergo/fix/2606-global-ignore-file

fix(config): honor core.excludesFile and .git/info/exclude for global ignores (#2606)
This commit is contained in:
Gergő Magyar 2026-07-21 20:34:40 +01:00 committed by GitHub
commit a259ec6c5a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 411 additions and 3 deletions

View file

@ -511,9 +511,19 @@ For very large repositories:
# Increase Node.js heap size # Increase Node.js heap size
NODE_OPTIONS="--max-old-space-size=16384" npx gitnexus analyze NODE_OPTIONS="--max-old-space-size=16384" npx gitnexus analyze
# Exclude large directories # Exclude large directories (this repo only)
echo "vendor/" >> .gitnexusignore echo "vendor/" >> .gitnexusignore
echo "dist/" >> .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 ### Large files are being skipped

View file

@ -3,6 +3,7 @@ import fs from 'fs/promises';
import nodePath from 'path'; import nodePath from 'path';
import type { Path } from 'path-scurry'; import type { Path } from 'path-scurry';
import { logger } from '../core/logger.js'; import { logger } from '../core/logger.js';
import { getCoreExcludesFilePath, getGitInfoExcludePath } from '../storage/git.js';
const DEFAULT_IGNORE_LIST = new Set([ const DEFAULT_IGNORE_LIST = new Set([
// Version Control // Version Control
@ -350,6 +351,8 @@ export const isHardcodedIgnoredDirectory = (name: string): boolean => {
export interface IgnoreOptions { export interface IgnoreOptions {
/** Skip .gitignore parsing, only read .gitnexusignore. Defaults to GITNEXUS_NO_GITIGNORE env var. */ /** Skip .gitignore parsing, only read .gitnexusignore. Defaults to GITNEXUS_NO_GITIGNORE env var. */
noGitignore?: boolean; 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 ( export const loadIgnoreRules = async (
@ -359,6 +362,32 @@ export const loadIgnoreRules = async (
const ig = ignore(); const ig = ignore();
let hasRules = false; 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) // Allow users to bypass .gitignore parsing (e.g. when .gitignore accidentally excludes source files)
const skipGitignore = options?.noGitignore ?? !!process.env.GITNEXUS_NO_GITIGNORE; const skipGitignore = options?.noGitignore ?? !!process.env.GITNEXUS_NO_GITIGNORE;
const filenames = skipGitignore ? ['.gitnexusignore'] : ['.gitignore', '.gitnexusignore']; const filenames = skipGitignore ? ['.gitnexusignore'] : ['.gitignore', '.gitnexusignore'];

View file

@ -1,6 +1,7 @@
import { execFileSync, execSync } from 'child_process'; import { execFileSync, execSync } from 'child_process';
import { statSync } from 'fs'; import { statSync } from 'fs';
import path from 'path'; import path from 'path';
import os from 'os';
// Git utilities for repository detection, commit tracking, and diff analysis // 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<string, string | null>();
const coreExcludesFilePathCache = new Map<string, string>();
/**
* 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 * Resolve `fromPath` to the directory whose basename should drive the
* registry name (#1259) the *identity root*. Three outcomes: * registry name (#1259) the *identity root*. Three outcomes:

View file

@ -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 { execSync } from 'child_process';
import fs from 'fs'; import fs from 'fs';
import os from 'os'; import os from 'os';
@ -12,6 +12,8 @@ import {
sanitizeRepoName, sanitizeRepoName,
getDefaultBranch, getDefaultBranch,
getCurrentBranch, getCurrentBranch,
getGitInfoExcludePath,
getCoreExcludesFilePath,
} from '../../src/storage/git.js'; } from '../../src/storage/git.js';
// Mock child_process.execSync // Mock child_process.execSync
@ -287,4 +289,120 @@ describe('git utilities', () => {
expect(parseRepoNameFromUrl(null)).toBeNull(); 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);
});
});
}); });

View file

@ -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 fs from 'fs/promises';
import path from 'path'; import path from 'path';
import os from 'os'; import os from 'os';
@ -9,6 +9,30 @@ import {
createIgnoreFilter, createIgnoreFilter,
} from '../../src/config/ignore-service.js'; } from '../../src/config/ignore-service.js';
import { _captureLogger } from '../../src/core/logger.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('shouldIgnorePath', () => {
describe('version control directories', () => { 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);
},
);
});