mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
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:
commit
a259ec6c5a
5 changed files with 411 additions and 3 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'];
|
||||
|
|
|
|||
|
|
@ -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<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
|
||||
* registry name (#1259) — the *identity root*. Three outcomes:
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue