Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions gitnexus/src/storage/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,17 @@ export const isGitRepo = (repoPath: string): boolean => {

export const getCurrentCommit = (repoPath: string): string => {
try {
return execSync('git rev-parse HEAD', { cwd: repoPath }).toString().trim();
return execSync('git rev-parse HEAD', {
cwd: repoPath,
// Suppress stderr -- without an explicit stdio option, Node's execSync
// forwards the child's stderr to the parent process (documented behaviour).
// When repoPath is not inside a git worktree, git prints
// "fatal: not a git repository" to stderr, which leaks to the user's
// terminal even though the error is caught here (#1172).
stdio: ['ignore', 'pipe', 'ignore'],
})
.toString()
.trim();
} catch {
return '';
}
Expand Down Expand Up @@ -86,7 +96,13 @@ export const getRemoteUrl = (repoPath: string): string | undefined => {
*/
export const getGitRoot = (fromPath: string): string | null => {
try {
const raw = execSync('git rev-parse --show-toplevel', { cwd: fromPath }).toString().trim();
const raw = execSync('git rev-parse --show-toplevel', {
cwd: fromPath,
// Suppress stderr -- see getCurrentCommit comment and #1172.
stdio: ['ignore', 'pipe', 'ignore'],
})
.toString()
.trim();
// On Windows, git returns /d/Projects/Foo — path.resolve normalizes to D:\Projects\Foo
return path.resolve(raw);
} catch {
Expand Down
37 changes: 36 additions & 1 deletion gitnexus/test/unit/git-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* Tests isGitRepo, getCurrentCommit, getGitRoot, and the newly added
* hasGitDir helper introduced for issue #384 (indexing non-git folders).
*/
import { describe, it, expect } from 'vitest';
import { describe, it, expect, vi } from 'vitest';
import path from 'path';
import os from 'os';
import fs from 'fs';
Expand Down Expand Up @@ -97,6 +97,26 @@ describe('getCurrentCommit', () => {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});

// Regression: #1172 — without explicit stdio on execSync, Node forwards
// the child's stderr to the parent process, printing "fatal: not a git
// repository" to the user's terminal even though the error is caught.
it('does not leak git stderr to process.stderr (#1172)', async () => {
const { getCurrentCommit } = await import('../../src/storage/git.js');
// git-init a dir without commits so `git rev-parse HEAD` fails with a
// "fatal:" message — the exact class of error that leaked before the fix.
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-test-'));
execSync('git init -q', { cwd: tmpDir, stdio: 'ignore' });
const spy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
try {
expect(getCurrentCommit(tmpDir)).toBe('');
const stderrOutput = spy.mock.calls.map((c) => String(c[0])).join('');
expect(stderrOutput).not.toContain('fatal');
} finally {
spy.mockRestore();
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
});

// ─── getGitRoot ───────────────────────────────────────────────────────────
Expand All @@ -111,6 +131,21 @@ describe('getGitRoot', () => {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});

// Regression: #1172 -- mirrors the getCurrentCommit stderr test above.
it('does not leak git stderr to process.stderr (#1172)', async () => {
const { getGitRoot } = await import('../../src/storage/git.js');
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-test-'));
const spy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
try {
getGitRoot(tmpDir);
const stderrOutput = spy.mock.calls.map((c) => String(c[0])).join('');
expect(stderrOutput).not.toContain('fatal');
} finally {
spy.mockRestore();
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
});

// ─── getRemoteUrl ─────────────────────────────────────────────────────────
Expand Down
Loading