GitNexus/gitnexus/test/unit/git.test.ts
Gergo Magyar 382801790c 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.
2026-07-21 19:09:50 +00:00

408 lines
16 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { execSync } from 'child_process';
import fs from 'fs';
import os from 'os';
import path from 'path';
import {
isGitRepo,
getCurrentCommit,
getGitRoot,
findGitRootByDotGit,
parseRepoNameFromUrl,
sanitizeRepoName,
getDefaultBranch,
getCurrentBranch,
getGitInfoExcludePath,
getCoreExcludesFilePath,
} from '../../src/storage/git.js';
// Mock child_process.execSync
vi.mock('child_process', () => ({
execSync: vi.fn(),
}));
const mockExecSync = vi.mocked(execSync);
describe('git utilities', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('isGitRepo', () => {
it('returns true when inside a git work tree', () => {
mockExecSync.mockReturnValueOnce(Buffer.from(''));
expect(isGitRepo('/project')).toBe(true);
expect(mockExecSync).toHaveBeenCalledWith('git rev-parse --is-inside-work-tree', {
cwd: '/project',
stdio: 'ignore',
windowsHide: true,
});
});
it('returns false when not a git repo', () => {
mockExecSync.mockImplementationOnce(() => {
throw new Error('not a git repo');
});
expect(isGitRepo('/not-a-repo')).toBe(false);
});
it('passes the correct cwd', () => {
mockExecSync.mockReturnValueOnce(Buffer.from(''));
isGitRepo('/some/path');
expect(mockExecSync).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ cwd: '/some/path' }),
);
});
});
describe('getCurrentCommit', () => {
it('returns trimmed commit hash', () => {
mockExecSync.mockReturnValueOnce(Buffer.from('abc123def\n'));
expect(getCurrentCommit('/project')).toBe('abc123def');
});
it('returns empty string on error', () => {
mockExecSync.mockImplementationOnce(() => {
throw new Error('not a git repo');
});
expect(getCurrentCommit('/not-a-repo')).toBe('');
});
it('trims whitespace from output', () => {
mockExecSync.mockReturnValueOnce(Buffer.from(' sha256hash \n'));
expect(getCurrentCommit('/project')).toBe('sha256hash');
});
});
describe('getDefaultBranch (#243)', () => {
it('strips the origin/ prefix from the symbolic ref', () => {
mockExecSync.mockReturnValueOnce(Buffer.from('origin/develop\n'));
expect(getDefaultBranch('/project')).toBe('develop');
expect(mockExecSync).toHaveBeenCalledWith(
'git symbolic-ref --short refs/remotes/origin/HEAD',
expect.objectContaining({ cwd: '/project', windowsHide: true }),
);
});
it('handles a branch name that itself contains a slash', () => {
mockExecSync.mockReturnValueOnce(Buffer.from('origin/release/1.2\n'));
expect(getDefaultBranch('/project')).toBe('release/1.2');
});
it('returns null when origin/HEAD is not set (git throws)', () => {
mockExecSync.mockImplementationOnce(() => {
throw new Error('fatal: ref refs/remotes/origin/HEAD is not a symbolic ref');
});
expect(getDefaultBranch('/no-origin-head')).toBeNull();
});
it('returns null on empty output', () => {
mockExecSync.mockReturnValueOnce(Buffer.from('\n'));
expect(getDefaultBranch('/project')).toBeNull();
});
});
describe('getCurrentBranch (#2106)', () => {
it('returns the checked-out branch name', () => {
mockExecSync.mockReturnValueOnce(Buffer.from('feature/login\n'));
expect(getCurrentBranch('/project')).toBe('feature/login');
expect(mockExecSync).toHaveBeenCalledWith(
'git rev-parse --abbrev-ref HEAD',
expect.objectContaining({ cwd: '/project', windowsHide: true }),
);
});
it('returns null for a detached HEAD (literal "HEAD")', () => {
mockExecSync.mockReturnValueOnce(Buffer.from('HEAD\n'));
expect(getCurrentBranch('/ci-checkout')).toBeNull();
});
it('returns null when not a git repo (git throws)', () => {
mockExecSync.mockImplementationOnce(() => {
throw new Error('fatal: not a git repository');
});
expect(getCurrentBranch('/not-a-repo')).toBeNull();
});
it('returns null on empty output', () => {
mockExecSync.mockReturnValueOnce(Buffer.from('\n'));
expect(getCurrentBranch('/project')).toBeNull();
});
it('preserves a slash in the branch name (slugging happens elsewhere)', () => {
mockExecSync.mockReturnValueOnce(Buffer.from('release/1.2\n'));
expect(getCurrentBranch('/project')).toBe('release/1.2');
});
});
describe('getGitRoot', () => {
it('returns resolved path on success', () => {
mockExecSync.mockReturnValueOnce(Buffer.from('/d/Projects/MyRepo\n'));
const result = getGitRoot('/d/Projects/MyRepo/src');
expect(result).toBeTruthy();
// path.resolve normalizes the git output
expect(typeof result).toBe('string');
});
it('returns null when not in a git repo', () => {
mockExecSync.mockImplementationOnce(() => {
throw new Error('not a git repo');
});
expect(getGitRoot('/not-a-repo')).toBeNull();
});
it('calls git rev-parse --show-toplevel', () => {
mockExecSync.mockReturnValueOnce(Buffer.from('/repo\n'));
getGitRoot('/repo/src');
expect(mockExecSync).toHaveBeenCalledWith(
'git rev-parse --show-toplevel',
expect.objectContaining({ cwd: '/repo/src' }),
);
});
it('preserves path whitespace while removing the trailing newline', () => {
mockExecSync.mockReturnValueOnce(Buffer.from('/repo \n'));
const result = getGitRoot('/repo/src');
expect(result).not.toBeNull();
expect(result).toBe(path.resolve('/repo '));
});
});
describe('findGitRootByDotGit', () => {
it('finds an ancestor .git directory without spawning git', () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-dotgit-'));
try {
fs.mkdirSync(path.join(tmpDir, '.git'));
const nested = path.join(tmpDir, 'packages', 'app');
fs.mkdirSync(nested, { recursive: true });
expect(findGitRootByDotGit(nested)).toBe(path.resolve(tmpDir));
expect(mockExecSync).not.toHaveBeenCalled();
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
it('returns null outside a git worktree without spawning git', () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-nonrepo-'));
try {
expect(findGitRootByDotGit(tmpDir)).toBeNull();
expect(mockExecSync).not.toHaveBeenCalled();
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
// Linked worktrees and submodules use a `.git` file (not directory) that
// points at the real gitdir. statSync succeeds for both, so the ancestor
// walk should treat such roots identically to ordinary repos.
it('treats a .git file (linked worktree) as a valid root', () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-worktree-'));
try {
fs.writeFileSync(path.join(tmpDir, '.git'), 'gitdir: /fake/worktrees/wt\n');
const nested = path.join(tmpDir, 'src', 'pkg');
fs.mkdirSync(nested, { recursive: true });
expect(findGitRootByDotGit(nested)).toBe(path.resolve(tmpDir));
expect(mockExecSync).not.toHaveBeenCalled();
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
it('returns null when the input path does not exist', () => {
const missing = path.join(os.tmpdir(), `gitnexus-missing-${Date.now()}-${Math.random()}`);
expect(findGitRootByDotGit(missing)).toBeNull();
expect(mockExecSync).not.toHaveBeenCalled();
});
it('walks from a file input by starting at its parent directory', () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-fileinput-'));
try {
fs.mkdirSync(path.join(tmpDir, '.git'));
const filePath = path.join(tmpDir, 'pkg', 'index.ts');
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, 'export {};\n');
expect(findGitRootByDotGit(filePath)).toBe(path.resolve(tmpDir));
expect(mockExecSync).not.toHaveBeenCalled();
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
});
describe('sanitizeRepoName', () => {
it('strips leading dashes', () => {
expect(sanitizeRepoName('--repo')).toBe('repo');
});
it('replaces unsafe characters with underscores', () => {
expect(sanitizeRepoName('repo<tag>')).toBe('repo_tag_');
expect(sanitizeRepoName('repo:name')).toBe('repo_name');
expect(sanitizeRepoName('repo"quoted"')).toBe('repo_quoted_');
});
it('blocks path traversal segments', () => {
expect(sanitizeRepoName('.')).toBe('unknown');
expect(sanitizeRepoName('..')).toBe('unknown');
});
it('blocks Windows reserved names', () => {
expect(sanitizeRepoName('CON')).toBe('unknown');
expect(sanitizeRepoName('prn')).toBe('unknown');
expect(sanitizeRepoName('AUX')).toBe('unknown');
expect(sanitizeRepoName('NUL')).toBe('unknown');
expect(sanitizeRepoName('COM1')).toBe('unknown');
expect(sanitizeRepoName('LPT9')).toBe('unknown');
// Reserved names with extensions
expect(sanitizeRepoName('CON.txt')).toBe('unknown');
expect(sanitizeRepoName('NUL.tar.gz')).toBe('unknown');
expect(sanitizeRepoName('AUX.local')).toBe('unknown');
});
it('returns unknown for empty or invalid input', () => {
expect(sanitizeRepoName('')).toBe('unknown');
expect(sanitizeRepoName('---')).toBe('unknown');
});
});
describe('parseRepoNameFromUrl', () => {
it('extracts and sanitizes name from HTTPS URL', () => {
expect(parseRepoNameFromUrl('https://github.com/user/my-repo.git')).toBe('my-repo');
expect(parseRepoNameFromUrl('https://github.com/user/--payload.git')).toBe('payload');
});
it('extracts and sanitizes name from SSH URL', () => {
expect(parseRepoNameFromUrl('git@github.com:user/my-repo.git')).toBe('my-repo');
expect(parseRepoNameFromUrl('git@github.com:--payload.git')).toBe('payload');
});
it('returns null for all-dash inputs (prevents registry collision)', () => {
expect(parseRepoNameFromUrl('https://github.com/user/---.git')).toBeNull();
});
it('returns null for empty URL', () => {
expect(parseRepoNameFromUrl('')).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);
});
});
});