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
53 changes: 51 additions & 2 deletions gitnexus-claude-plugin/hooks/gitnexus-hook.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,12 @@ function isGlobalRegistryDir(candidate) {
);
}

function findGitNexusDir(startDir) {
let dir = startDir || process.cwd();
/**
* Walk up from `startDir` looking for a non-registry `.gitnexus/` folder.
* Returns the path to `.gitnexus/` or null if not found within 5 levels.
*/
function walkForGitNexusDir(startDir) {
let dir = startDir;
for (let i = 0; i < 5; i++) {
const candidate = path.join(dir, '.gitnexus');
if (fs.existsSync(candidate)) {
Expand All @@ -53,6 +57,51 @@ function findGitNexusDir(startDir) {
return null;
}

/**
* Resolve the canonical (main) worktree root for `cwd`, when `cwd` is inside
* any git working tree — including a *linked* worktree created via
* `git worktree add`. Linked worktrees never contain `.gitnexus/`, so the
* upward walk from cwd alone misses the index. Returns null when `cwd` is
* not inside a git repo or `git` is not available.
*
* Implementation: `git rev-parse --git-common-dir` resolves to the canonical
* `.git/` directory (or `.git/worktrees/...` parent) that is shared across
* all linked worktrees. The canonical repo root is its parent directory.
*/
function findCanonicalRepoRoot(cwd) {
try {
const result = spawnSync('git', ['rev-parse', '--path-format=absolute', '--git-common-dir'], {
encoding: 'utf-8',
timeout: 2000,
cwd,
stdio: ['pipe', 'pipe', 'pipe'],
});
if (result.error || result.status !== 0) return null;
const commonDir = (result.stdout || '').trim();
if (!commonDir || !path.isAbsolute(commonDir)) return null;
return path.dirname(commonDir);
} catch {
return null;
}
}

function findGitNexusDir(startDir) {
const cwd = startDir || process.cwd();

// Fast path: the cwd is inside the canonical repo (most common case).
const fromCwd = walkForGitNexusDir(cwd);
if (fromCwd) return fromCwd;

// Fallback: cwd may be inside a linked git worktree whose `.gitnexus/`
// only lives in the canonical repo root. Resolve the shared git dir
// and retry from there.
const canonicalRoot = findCanonicalRepoRoot(cwd);
if (canonicalRoot && canonicalRoot !== cwd) {
return walkForGitNexusDir(canonicalRoot);
}
return null;
}

/**
* Extract search pattern from tool input.
*/
Expand Down
53 changes: 51 additions & 2 deletions gitnexus/hooks/claude/gitnexus-hook.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,12 @@ function isGlobalRegistryDir(candidate) {
);
}

function findGitNexusDir(startDir) {
let dir = startDir || process.cwd();
/**
* Walk up from `startDir` looking for a non-registry `.gitnexus/` folder.
* Returns the path to `.gitnexus/` or null if not found within 5 levels.
*/
function walkForGitNexusDir(startDir) {
let dir = startDir;
for (let i = 0; i < 5; i++) {
const candidate = path.join(dir, '.gitnexus');
if (fs.existsSync(candidate)) {
Expand All @@ -53,6 +57,51 @@ function findGitNexusDir(startDir) {
return null;
}

/**
* Resolve the canonical (main) worktree root for `cwd`, when `cwd` is inside
* any git working tree — including a *linked* worktree created via
* `git worktree add`. Linked worktrees never contain `.gitnexus/`, so the
* upward walk from cwd alone misses the index. Returns null when `cwd` is
* not inside a git repo or `git` is not available.
*
* Implementation: `git rev-parse --git-common-dir` resolves to the canonical
* `.git/` directory (or `.git/worktrees/...` parent) that is shared across
* all linked worktrees. The canonical repo root is its parent directory.
*/
function findCanonicalRepoRoot(cwd) {
try {
const result = spawnSync('git', ['rev-parse', '--path-format=absolute', '--git-common-dir'], {
encoding: 'utf-8',
timeout: 2000,
cwd,
stdio: ['pipe', 'pipe', 'pipe'],
});
if (result.error || result.status !== 0) return null;
const commonDir = (result.stdout || '').trim();
if (!commonDir || !path.isAbsolute(commonDir)) return null;
return path.dirname(commonDir);
} catch {
return null;
}
}

function findGitNexusDir(startDir) {
const cwd = startDir || process.cwd();

// Fast path: the cwd is inside the canonical repo (most common case).
const fromCwd = walkForGitNexusDir(cwd);
if (fromCwd) return fromCwd;

// Fallback: cwd may be inside a linked git worktree whose `.gitnexus/`
// only lives in the canonical repo root. Resolve the shared git dir
// and retry from there.
const canonicalRoot = findCanonicalRepoRoot(cwd);
if (canonicalRoot && canonicalRoot !== cwd) {
return walkForGitNexusDir(canonicalRoot);
}
return null;
}

/**
* Extract search pattern from tool input.
*/
Expand Down
35 changes: 33 additions & 2 deletions gitnexus/src/core/lbug/lbug-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,19 @@ export const isDbBusyError = (err: unknown): boolean => {
);
};

/**
* Return true when the error message indicates a write was attempted against
* a read-only LadybugDB connection. The MCP query pool opens DBs read-only,
* so any path that calls a `CREATE_*` procedure there will surface this
* (e.g. defensive `ensureFTSIndex` calls). Owners of the writable analyze
* path should ignore this error — index creation is owned by `gitnexus
* analyze` and either already happened or will happen on the next run.
*/
export const isReadOnlyDbError = (err: unknown): boolean => {
const msg = err instanceof Error ? err.message : String(err);
return /read-only database/i.test(msg);
};

const runWithSessionLock = async <T>(operation: () => Promise<T>): Promise<T> => {
const previous = sessionLock;
let release: (() => void) | null = null;
Expand Down Expand Up @@ -1239,6 +1252,13 @@ export const createFTSIndex = async (
*
* Safe to call repeatedly — the in-process Set guarantees only the first
* call hits LadybugDB. `closeLbug` clears the cache so re-init starts fresh.
*
* Defense in depth: if the active connection is read-only (e.g. the MCP
* pool adapter), `CREATE_FTS_INDEX` will fail with "Cannot execute write
* operations in a read-only database". Treat that as a no-op and cache
* the key so callers don't loop on a path that can never succeed here —
* the index is owned by `gitnexus analyze` (writable) and either already
* exists or will be created on the next analyze.
*/
export const ensureFTSIndex = async (
tableName: string,
Expand All @@ -1248,8 +1268,19 @@ export const ensureFTSIndex = async (
): Promise<void> => {
const key = `${tableName}:${indexName}`;
if (ensuredFTSIndexes.has(key)) return;
await createFTSIndex(tableName, indexName, properties, stemmer);
ensuredFTSIndexes.add(key);
try {
await createFTSIndex(tableName, indexName, properties, stemmer);
ensuredFTSIndexes.add(key);
} catch (e) {
// Read-only DB: writable analyze owns index creation; silently skip
// and cache so callers don't loop on a path that can never succeed
// here (the MCP query pool opens DBs read-only by design).
if (isReadOnlyDbError(e)) {
ensuredFTSIndexes.add(key);
return;
}
throw e;
}
};

/**
Expand Down
9 changes: 4 additions & 5 deletions gitnexus/src/mcp/local/local-backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1110,13 +1110,12 @@ export class LocalBackend {
}
} else if (!this.warnedVectorUnsupported) {
// Rare diagnostic: surface why we fell back to the exact scan path so
// operators can see at a glance that the VECTOR extension is missing on
// this runtime (e.g. Windows builds without the optional native
// dependency). Emitted once per `LocalBackend` instance lifetime to
// avoid noisy stderr on hot semantic-search paths (DoD §2.8).
// operators can see at a glance that VECTOR is disabled by platform
// policy. Emitted once per `LocalBackend` instance lifetime to avoid
// noisy stderr on hot semantic-search paths (DoD §2.8).
this.warnedVectorUnsupported = true;
console.error(
'GitNexus [query:vector]: VECTOR index unavailable for this runtime; using exact scan fallback',
'GitNexus [query:vector]: VECTOR extension not supported on this platform; using exact scan fallback',
);
}

Expand Down
14 changes: 14 additions & 0 deletions gitnexus/test/integration/lbug-core-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,20 @@ withTestLbugDB(
).resolves.toBeUndefined();
});

it('ensureFTSIndex is idempotent and caches across writable calls (#1224)', async () => {
const { ensureFTSIndex } = await import('../../src/core/lbug/lbug-adapter.js');

// First call creates the index. Second call must short-circuit on the
// in-process cache — guarantees the read-only guard added in #1224
// still respects the success path.
await expect(
ensureFTSIndex('Function', 'function_fts_ensure', ['name', 'content']),
).resolves.toBeUndefined();
await expect(
ensureFTSIndex('Function', 'function_fts_ensure', ['name', 'content']),
).resolves.toBeUndefined();
});

it('getLbugStats returns valid counts', async () => {
const { getLbugStats } = await import('../../src/core/lbug/lbug-adapter.js');

Expand Down
18 changes: 17 additions & 1 deletion gitnexus/test/unit/calltool-dispatch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,13 +218,29 @@ describe('LocalBackend.callTool', () => {
),
).toBe(true);
expect(consoleError).toHaveBeenCalledWith(
expect.stringContaining('GitNexus [query:vector]: VECTOR index unavailable'),
expect.stringContaining(
'GitNexus [query:vector]: VECTOR extension not supported on this platform',
),
);
} finally {
consoleError.mockRestore();
}
});

it('issues vector index query when VECTOR is supported by the platform', async () => {
platformMocks.isVectorExtensionSupportedByPlatform.mockReturnValue(true);
(executeQuery as any).mockImplementation(async (_repoId: string, cypher: string) => {
if (cypher.includes('COUNT(*) AS cnt')) return [{ cnt: 1 }];
return [];
});
(executeParameterized as any).mockResolvedValue([]);

await backend.callTool('query', { query: 'auth' });

const queries = (executeQuery as any).mock.calls.map(([, cypher]: [string, string]) => cypher);
expect(queries.some((cypher: string) => cypher.includes('QUERY_VECTOR_INDEX'))).toBe(true);
});

it('query tool returns error for empty query', async () => {
const result = await backend.callTool('query', { query: '' });
expect(result.error).toContain('query parameter is required');
Expand Down
76 changes: 76 additions & 0 deletions gitnexus/test/unit/hooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -602,6 +602,82 @@ describe('Global registry lookup', () => {
}
});

// ─── Integration: linked-worktree resolution (#1224) ───────────────

describe('Linked git worktree resolution', () => {
for (const [label, hookPath] of [
['CJS', CJS_HOOK],
['Plugin', PLUGIN_HOOK],
] as const) {
it(`${label}: PostToolUse emits stale from a linked worktree pointing at an indexed canonical repo`, () => {
// Layout mirrors `git worktree add ../<repo>-worktrees/feature-x`:
// <root>/main-repo/.git (canonical)
// <root>/main-repo/.gitnexus/ (only here)
// <root>/main-repo-worktrees/feat/ (linked worktree, no .gitnexus)
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-worktree-'));
const mainRepo = path.join(root, 'main-repo');
const worktreePath = path.join(root, 'main-repo-worktrees', 'feat');
try {
fs.mkdirSync(mainRepo, { recursive: true });
initGitRepo(mainRepo);
fs.mkdirSync(path.join(mainRepo, '.gitnexus'), { recursive: true });
fs.writeFileSync(
path.join(mainRepo, '.gitnexus', 'meta.json'),
JSON.stringify({ lastCommit: 'oldcommit', stats: {} }),
);

// Create the linked worktree on a new branch.
fs.mkdirSync(path.dirname(worktreePath), { recursive: true });
runGit(mainRepo, ['worktree', 'add', '-b', 'feat', worktreePath]);

// Sanity: walking up from the worktree never reaches `.gitnexus`.
expect(fs.existsSync(path.join(worktreePath, '.gitnexus'))).toBe(false);
expect(fs.existsSync(path.join(path.dirname(worktreePath), '.gitnexus'))).toBe(false);

const result = runHook(hookPath, {
hook_event_name: 'PostToolUse',
tool_name: 'Bash',
tool_input: { command: 'git commit -m "test"' },
tool_output: { exit_code: 0 },
cwd: worktreePath,
});

const output = parseHookOutput(result.stdout);
expect(output).not.toBeNull();
expect(output!.additionalContext).toContain('stale');
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});

it(`${label}: PostToolUse silent from a linked worktree when canonical repo has no .gitnexus`, () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-worktree-'));
const mainRepo = path.join(root, 'main-repo');
const worktreePath = path.join(root, 'main-repo-worktrees', 'feat');
try {
fs.mkdirSync(mainRepo, { recursive: true });
initGitRepo(mainRepo);
// Note: NO .gitnexus/ in the canonical repo.

fs.mkdirSync(path.dirname(worktreePath), { recursive: true });
runGit(mainRepo, ['worktree', 'add', '-b', 'feat', worktreePath]);

const result = runHook(hookPath, {
hook_event_name: 'PostToolUse',
tool_name: 'Bash',
tool_input: { command: 'git commit -m "test"' },
tool_output: { exit_code: 0 },
cwd: worktreePath,
});

expect(result.stdout.trim()).toBe('');
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
}
});

// ─── Integration: dispatch map routes correctly ─────────────────────

describe('Dispatch map routing (integration)', () => {
Expand Down
55 changes: 55 additions & 0 deletions gitnexus/test/unit/lbug-readonly-error.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/**
* Regression Tests: read-only DB error discriminator (#1224)
*
* The MCP query pool opens LadybugDB read-only. Defensive callers of
* `ensureFTSIndex` from that pool used to spam stderr with five
* "Cannot execute write operations in a read-only database" warnings
* per query because the cache was invalidated each time. The fix:
* `ensureFTSIndex` now treats the read-only error as a no-op and
* caches the key — but to do that it relies on a precise discriminator
* that does NOT swallow lock / busy / "already exists" errors.
*
* This file unit-tests the discriminator directly so future refactors
* keep the contract.
*/
import { describe, it, expect } from 'vitest';
import { isReadOnlyDbError } from '../../src/core/lbug/lbug-adapter.js';

describe('isReadOnlyDbError', () => {
it('matches the canonical LadybugDB read-only message verbatim', () => {
const err = new Error(
'Connection exception: Cannot execute write operations in a read-only database!',
);
expect(isReadOnlyDbError(err)).toBe(true);
});

it('matches when the error is wrapped in additional prefix text', () => {
const err = new Error(
'Runtime exception: Cannot execute write operations in a read-only database',
);
expect(isReadOnlyDbError(err)).toBe(true);
});

it('is case-insensitive on the "read-only" substring', () => {
expect(isReadOnlyDbError(new Error('Read-Only Database access denied'))).toBe(true);
});

it('accepts non-Error values (string, unknown) without throwing', () => {
expect(isReadOnlyDbError('write rejected: read-only database')).toBe(true);
expect(isReadOnlyDbError({ toString: () => 'read-only database' })).toBe(true);
expect(isReadOnlyDbError(null)).toBe(false);
expect(isReadOnlyDbError(undefined)).toBe(false);
});

it('does NOT match unrelated errors that the ensure path must still surface', () => {
// Lock contention — handled separately by isDbBusyError; must not be
// silenced by the read-only filter.
expect(isReadOnlyDbError(new Error('Could not set lock on file'))).toBe(false);
// "already exists" — the happy idempotent path inside createFTSIndex.
expect(isReadOnlyDbError(new Error('Index file_fts already exists'))).toBe(false);
// Schema-level problem.
expect(isReadOnlyDbError(new Error('Table File does not exist'))).toBe(false);
// Generic transient error.
expect(isReadOnlyDbError(new Error('Connection refused'))).toBe(false);
});
});
Loading