fix(claude): skip augment hook when server owns db

This commit is contained in:
Derek Pearson 2026-05-11 00:12:08 -04:00
parent d69eadfb7f
commit f9c70fcf81
No known key found for this signature in database
5 changed files with 222 additions and 9 deletions

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -63,7 +63,7 @@
"vitest": "^4.0.18"
},
"engines": {
"node": ">=20.0.0"
"node": ">=22.0.0"
},
"optionalDependencies": {
"node-addon-api": "^8.0.0",

View file

@ -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)', () => {

View file

@ -7,12 +7,14 @@ export function runHook(
hookPath: string,
input: Record<string, any>,
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 {