diff --git a/gitnexus-claude-plugin/hooks/gitnexus-hook.js b/gitnexus-claude-plugin/hooks/gitnexus-hook.js index 7d8fbfda4..cd65750ba 100644 --- a/gitnexus-claude-plugin/hooks/gitnexus-hook.js +++ b/gitnexus-claude-plugin/hooks/gitnexus-hook.js @@ -102,6 +102,43 @@ function findGitNexusDir(startDir) { return null; } +function isGitNexusServerCommand(command) { + const hasServerMode = /(?:^|\s)(mcp|serve)(?:\s|$)/.test(command); + const hasGitNexus = /(?:^|[/\\\s])gitnexus(?:\.cmd)?(?:\s|$)/.test(command) || + /node_modules[/\\]gitnexus[/\\]/.test(command); + return hasServerMode && hasGitNexus; +} + +function hasGitNexusServerOwner(gitNexusDir) { + const dbPath = path.join(gitNexusDir, 'lbug'); + if (process.platform === 'win32' || !fs.existsSync(dbPath)) return false; + + const lsof = spawnSync('lsof', ['-nP', '-t', '--', dbPath], { + encoding: 'utf-8', + timeout: 1000, + stdio: ['ignore', 'pipe', 'ignore'], + }); + if (lsof.error) return lsof.error.code === 'ETIMEDOUT'; + + const pids = (lsof.stdout || '').split(/\s+/).filter(Boolean); + for (const pid of pids) { + if (Number(pid) === process.pid) continue; + const ps = spawnSync('ps', ['-p', pid, '-o', 'command='], { + encoding: 'utf-8', + timeout: 500, + stdio: ['ignore', 'pipe', 'ignore'], + }); + if (!ps.error && isGitNexusServerCommand(ps.stdout || '')) return true; + } + return false; +} + +function extractAugmentContext(stderr) { + const output = (stderr || '').trim(); + const marker = output.indexOf('[GitNexus]'); + return marker === -1 ? '' : output.slice(marker).trim(); +} + /** * Extract search pattern from tool input. */ @@ -217,7 +254,8 @@ function sendHookResponse(hookEventName, message) { function handlePreToolUse(input) { const cwd = input.cwd || process.cwd(); if (!path.isAbsolute(cwd)) return; - if (!findGitNexusDir(cwd)) return; + const gitNexusDir = findGitNexusDir(cwd); + if (!gitNexusDir) return; const toolName = input.tool_name || ''; const toolInput = input.tool_input || {}; @@ -226,19 +264,20 @@ function handlePreToolUse(input) { const pattern = extractPattern(toolName, toolInput); if (!pattern || pattern.length < 3) return; + if (hasGitNexusServerOwner(gitNexusDir)) return; let result = ''; try { const child = runGitNexusCli(['augment', '--', pattern], cwd, 7000); if (!child.error && child.status === 0) { - result = child.stderr || ''; + result = extractAugmentContext(child.stderr || ''); } } catch { /* graceful failure */ } - if (result && result.trim()) { - sendHookResponse('PreToolUse', result.trim()); + if (result) { + sendHookResponse('PreToolUse', result); } } diff --git a/gitnexus/hooks/claude/gitnexus-hook.cjs b/gitnexus/hooks/claude/gitnexus-hook.cjs index 7bfa150cd..6ec8bd3ee 100755 --- a/gitnexus/hooks/claude/gitnexus-hook.cjs +++ b/gitnexus/hooks/claude/gitnexus-hook.cjs @@ -102,6 +102,43 @@ function findGitNexusDir(startDir) { return null; } +function isGitNexusServerCommand(command) { + const hasServerMode = /(?:^|\s)(mcp|serve)(?:\s|$)/.test(command); + const hasGitNexus = /(?:^|[/\\\s])gitnexus(?:\.cmd)?(?:\s|$)/.test(command) || + /node_modules[/\\]gitnexus[/\\]/.test(command); + return hasServerMode && hasGitNexus; +} + +function hasGitNexusServerOwner(gitNexusDir) { + const dbPath = path.join(gitNexusDir, 'lbug'); + if (process.platform === 'win32' || !fs.existsSync(dbPath)) return false; + + const lsof = spawnSync('lsof', ['-nP', '-t', '--', dbPath], { + encoding: 'utf-8', + timeout: 1000, + stdio: ['ignore', 'pipe', 'ignore'], + }); + if (lsof.error) return lsof.error.code === 'ETIMEDOUT'; + + const pids = (lsof.stdout || '').split(/\s+/).filter(Boolean); + for (const pid of pids) { + if (Number(pid) === process.pid) continue; + const ps = spawnSync('ps', ['-p', pid, '-o', 'command='], { + encoding: 'utf-8', + timeout: 500, + stdio: ['ignore', 'pipe', 'ignore'], + }); + if (!ps.error && isGitNexusServerCommand(ps.stdout || '')) return true; + } + return false; +} + +function extractAugmentContext(stderr) { + const output = (stderr || '').trim(); + const marker = output.indexOf('[GitNexus]'); + return marker === -1 ? '' : output.slice(marker).trim(); +} + /** * Extract search pattern from tool input. */ @@ -167,6 +204,9 @@ function extractPattern(toolName, toolInput) { * 3. Fall back to npx (returns empty string) */ function resolveCliPath() { + if (process.env.GITNEXUS_HOOK_CLI_PATH !== undefined) { + return process.env.GITNEXUS_HOOK_CLI_PATH; + } let cliPath = path.resolve(__dirname, '..', '..', 'dist', 'cli', 'index.js'); if (!fs.existsSync(cliPath)) { try { @@ -207,7 +247,8 @@ function runGitNexusCli(cliPath, args, cwd, timeout) { function handlePreToolUse(input) { const cwd = input.cwd || process.cwd(); if (!path.isAbsolute(cwd)) return; - if (!findGitNexusDir(cwd)) return; + const gitNexusDir = findGitNexusDir(cwd); + if (!gitNexusDir) return; const toolName = input.tool_name || ''; const toolInput = input.tool_input || {}; @@ -216,20 +257,21 @@ function handlePreToolUse(input) { const pattern = extractPattern(toolName, toolInput); if (!pattern || pattern.length < 3) return; + if (hasGitNexusServerOwner(gitNexusDir)) return; const cliPath = resolveCliPath(); let result = ''; try { const child = runGitNexusCli(cliPath, ['augment', '--', pattern], cwd, 7000); if (!child.error && child.status === 0) { - result = child.stderr || ''; + result = extractAugmentContext(child.stderr || ''); } } catch { /* graceful failure */ } - if (result && result.trim()) { - sendHookResponse('PreToolUse', result.trim()); + if (result) { + sendHookResponse('PreToolUse', result); } } diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index dfc5e3c4c..7aa9dc7bf 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -63,7 +63,7 @@ "vitest": "^4.0.18" }, "engines": { - "node": ">=20.0.0" + "node": ">=22.0.0" }, "optionalDependencies": { "node-addon-api": "^8.0.0", diff --git a/gitnexus/test/unit/hooks.test.ts b/gitnexus/test/unit/hooks.test.ts index da19da002..c7c236c49 100644 --- a/gitnexus/test/unit/hooks.test.ts +++ b/gitnexus/test/unit/hooks.test.ts @@ -18,6 +18,7 @@ */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { spawnSync } from 'child_process'; +import type { WriteFileOptions } from 'fs'; import fs from 'fs'; import path from 'path'; import os from 'os'; @@ -99,6 +100,45 @@ function createGlobalRegistry(homeDir: string, marker: 'both' | 'registry' | 're } } +function writeExecutable(filePath: string, content: string) { + fs.writeFileSync(filePath, content, { mode: 0o755 } as WriteFileOptions); +} + +function createHookToolDir(options: { + gitnexusStderr?: string; + gitnexusMarkerPath?: string; + lsofOutput?: string; + psOutput?: string; +}) { + const binDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-hook-bin-')); + const gitnexusStderr = JSON.stringify(options.gitnexusStderr ?? ''); + const markerPath = JSON.stringify(options.gitnexusMarkerPath ?? ''); + + const fakeGitNexus = `#!/usr/bin/env node\nconst fs = require('fs');\nconst marker = ${markerPath};\nif (marker) fs.writeFileSync(marker, 'called');\nprocess.stderr.write(${gitnexusStderr});\n`; + writeExecutable(path.join(binDir, 'gitnexus'), fakeGitNexus); + writeExecutable(path.join(binDir, 'gitnexus-cli.js'), fakeGitNexus); + + writeExecutable( + path.join(binDir, 'lsof'), + `#!/usr/bin/env node\nprocess.stdout.write(${JSON.stringify(options.lsofOutput ?? '')});\nprocess.exit(0);\n`, + ); + + writeExecutable( + path.join(binDir, 'ps'), + `#!/usr/bin/env node\nprocess.stdout.write(${JSON.stringify(options.psOutput ?? '')});\nprocess.exit(0);\n`, + ); + + return binDir; +} + +function hookEnv(binDir: string) { + return { + ...process.env, + PATH: `${binDir}${path.delimiter}${process.env.PATH || ''}`, + GITNEXUS_HOOK_CLI_PATH: path.join(binDir, 'gitnexus-cli.js'), + }; +} + // ─── Both hook files should exist ─────────────────────────────────── describe('Hook files exist', () => { @@ -294,6 +334,96 @@ describe('Git mutation regex', () => { } }); +// ─── Integration: PreToolUse augmentation filtering ───────────────── + +describe('PreToolUse augmentation filtering (integration)', () => { + for (const [label, hookPath] of [ + ['CJS', CJS_HOOK], + ['Plugin', PLUGIN_HOOK], + ] as const) { + it(`${label}: emits valid GitNexus augmentation context`, () => { + const binDir = createHookToolDir({ + gitnexusStderr: '[GitNexus] 1 related symbol found:\n\nvalidateUser (src/auth.ts)\n', + }); + try { + const result = runHook( + hookPath, + { + hook_event_name: 'PreToolUse', + tool_name: 'Grep', + tool_input: { pattern: 'validateUser' }, + cwd: tmpDir, + }, + undefined, + { env: hookEnv(binDir) }, + ); + + const output = parseHookOutput(result.stdout); + expect(output).not.toBeNull(); + expect(output!.hookEventName).toBe('PreToolUse'); + expect(output!.additionalContext).toContain('[GitNexus] 1 related symbol found'); + } finally { + fs.rmSync(binDir, { recursive: true, force: true }); + } + }); + + it(`${label}: suppresses LadybugDB lock warnings from augment stderr`, () => { + const binDir = createHookToolDir({ + gitnexusStderr: + 'GitNexus: FTS extension load failed: IO exception: Could not set lock on file : /tmp/repo/.gitnexus/lbug\n', + }); + try { + const result = runHook( + hookPath, + { + hook_event_name: 'PreToolUse', + tool_name: 'Grep', + tool_input: { pattern: 'validateUser' }, + cwd: tmpDir, + }, + undefined, + { env: hookEnv(binDir) }, + ); + + expect(result.stdout.trim()).toBe(''); + } finally { + fs.rmSync(binDir, { recursive: true, force: true }); + } + }); + + it(`${label}: skips augment when a GitNexus MCP process owns the repo DB`, () => { + const markerPath = path.join(os.tmpdir(), `gitnexus-hook-called-${process.pid}-${label}`); + const lbugPath = path.join(gitNexusDir, 'lbug'); + fs.writeFileSync(lbugPath, ''); + fs.rmSync(markerPath, { force: true }); + const binDir = createHookToolDir({ + gitnexusMarkerPath: markerPath, + lsofOutput: '12345\n', + psOutput: 'node /tmp/node_modules/.bin/gitnexus mcp\n', + }); + try { + const result = runHook( + hookPath, + { + hook_event_name: 'PreToolUse', + tool_name: 'Grep', + tool_input: { pattern: 'validateUser' }, + cwd: tmpDir, + }, + undefined, + { env: hookEnv(binDir) }, + ); + + expect(result.stdout.trim()).toBe(''); + expect(fs.existsSync(markerPath)).toBe(false); + } finally { + fs.rmSync(markerPath, { force: true }); + fs.rmSync(binDir, { recursive: true, force: true }); + } + }); + } +}); + // ─── Integration: PostToolUse staleness detection ─────────────────── describe('PostToolUse staleness detection (integration)', () => { diff --git a/gitnexus/test/utils/hook-test-helpers.ts b/gitnexus/test/utils/hook-test-helpers.ts index 6f5c5fbfd..3f519bc81 100644 --- a/gitnexus/test/utils/hook-test-helpers.ts +++ b/gitnexus/test/utils/hook-test-helpers.ts @@ -7,12 +7,14 @@ export function runHook( hookPath: string, input: Record, cwd?: string, + options: { env?: NodeJS.ProcessEnv } = {}, ): { stdout: string; stderr: string; status: number | null } { const result = spawnSync(process.execPath, [hookPath], { input: JSON.stringify(input), encoding: 'utf-8', timeout: 10000, cwd, + env: options.env, stdio: ['pipe', 'pipe', 'pipe'], }); return {