GitNexus/gitnexus/test/unit/git.test.ts
Gergő Magyar 114d5304d9
fix(mcp): avoid git from non-repo cwd in sibling cwd match (#1138) (#1293)
* fix(mcp): avoid git shellout from non-repo cwd for sibling match

checkCwdMatch used getGitRoot(cwd), which runs git rev-parse from the
launch cwd (often \C:\Users\gergo in MCP stdio). Resolve the cwd git root via
ancestor .git checks first, then keep existing remote-based sibling
logic.

Fixes #1138

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(mcp): address PR #1293 review follow-ups

Three test gaps flagged by review on the #1138 fix:

- sibling-clone-drift.test.ts: the existing "non-git cwd" test only
  asserted match=none, which the pre-fix code also returned (by
  silently failing the spawn). Wrap child_process / node:child_process
  with passthrough vi.fn() spies and assert no execSync/execFileSync
  call is recorded when checkCwdMatch runs against a non-git cwd, so a
  regression that re-introduces the spawn fails loudly.
- git.test.ts: add coverage for findGitRootByDotGit's three untested
  inputs — a `.git` FILE (linked worktree / submodule), a path that
  does not exist, and a file path inside a repo (must walk from the
  parent dir). Each asserts no subprocess was spawned.

No production code changes. Test additions only.

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-03 09:17:12 +01:00

167 lines
5.7 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { execSync } from 'child_process';
import fs from 'fs';
import os from 'os';
import path from 'path';
import {
isGitRepo,
getCurrentCommit,
getGitRoot,
findGitRootByDotGit,
} 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',
});
});
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('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('trims output before resolving path', () => {
mockExecSync.mockReturnValueOnce(Buffer.from(' /repo \n'));
const result = getGitRoot('/repo/src');
expect(result).not.toBeNull();
expect(result!.trim()).toBe(result);
});
});
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 });
}
});
});
});