Compare commits

...
Author SHA1 Message Date
github-actions[bot] 897fbefad7 release: v1.6.4-rc.67 2026-05-05 13:47:51 +00:00
azizur100389 4048f53e35 fix(git): suppress stderr leak in getCurrentCommit and getGitRoot (#1172) (#1341)
* fix(git): suppress stderr leak in getCurrentCommit and getGitRoot (#1172)

Node's execSync forwards the child's stderr to the parent process when
the stdio option is not explicitly set. getCurrentCommit and getGitRoot
both caught the resulting error but did not suppress the stderr output,
causing "fatal: not a git repository" messages to leak to the terminal
whenever they were called on a path outside a git worktree.

Add stdio: ['ignore', 'pipe', 'ignore'] to both functions, matching the
pattern already used by getRemoteUrl, getRemoteOriginUrl, and
getCanonicalRepoRoot in the same file.

* address review: add getGitRoot stderr test, normalize em dashes to ASCII

- Add matching process.stderr.write spy test for getGitRoot (#1172)
- Replace U+2014 em dashes with ASCII -- in new comments
2026-05-05 14:29:42 +01:00
GoGoLin 027340292f fix(embeddings): add CHECKPOINT before closing database to prevent WAL corruption (#1314) 2026-05-05 13:35:22 +01:00
5 changed files with 88 additions and 6 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "gitnexus",
"version": "1.6.3",
"version": "1.6.4-rc.67",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "gitnexus",
"version": "1.6.3",
"version": "1.6.4-rc.67",
"hasInstallScript": true,
"license": "PolyForm-Noncommercial-1.0.0",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "gitnexus",
"version": "1.6.3",
"version": "1.6.4-rc.67",
"description": "Graph-powered code intelligence for AI agents. Index any codebase, query via MCP or CLI.",
"author": "Abhigyan Patwari",
"license": "PolyForm-Noncommercial-1.0.0",
+31
View File
@@ -257,6 +257,14 @@ export const withLbugDb = async <T>(dbPath: string, operation: () => Promise<T>)
// Close stale connection inside the session lock to prevent race conditions
// with concurrent operations that might acquire the lock between cleanup steps
await runWithSessionLock(async () => {
// CHECKPOINT before close to flush WAL contents (same rationale as closeLbug)
if (conn) {
try {
await conn.query('CHECKPOINT');
} catch {
/* best-effort */
}
}
try {
if (conn) await conn.close();
} catch {
@@ -294,6 +302,14 @@ const ensureLbugInitialized = async (dbPath: string) => {
const doInitLbug = async (dbPath: string) => {
// Different database requested — close the old one first
if (conn || db) {
// CHECKPOINT before close to flush WAL contents (same rationale as closeLbug)
if (conn) {
try {
await conn.query('CHECKPOINT');
} catch {
/* ignore — older LadybugDB or schemaless DB may not accept it */
}
}
try {
if (conn) await conn.close();
} catch {}
@@ -1048,6 +1064,21 @@ export const fetchExistingEmbeddingHashes = async (
};
export const closeLbug = async (): Promise<void> => {
// CHECKPOINT before close so the WAL/.shadow contents are flushed into
// the main database file. Without this, LadybugDB 0.16.0's non-blocking
// checkpoint thread can outlive the close call and leave sidecar pages
// pending on disk, which makes a subsequent read-side open either race
// with the WAL replay or trip the database-id check on the sidecars.
// This is especially critical after embedding writes, which generate
// large amounts of WAL data. CHECKPOINT is a no-op when there's nothing
// pending, so it's cheap on the happy path.
if (conn) {
try {
await conn.query('CHECKPOINT');
} catch {
/* ignore — older LadybugDB or schemaless DB may not accept it */
}
}
if (conn) {
try {
await conn.close();
+18 -2
View File
@@ -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 '';
}
@@ -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 {
+36 -1
View File
@@ -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';
@@ -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 ───────────────────────────────────────────────────────────
@@ -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 ─────────────────────────────────────────────────────────