diff --git a/gitnexus-claude-plugin/hooks/gitnexus-hook.js b/gitnexus-claude-plugin/hooks/gitnexus-hook.js index 91c20c9d22..bbe90dbc64 100644 --- a/gitnexus-claude-plugin/hooks/gitnexus-hook.js +++ b/gitnexus-claude-plugin/hooks/gitnexus-hook.js @@ -185,6 +185,48 @@ function extractPattern(toolName, toolInput) { return null; } +function commandExistsOnPath(command) { + const pathValue = process.env.PATH || process.env.Path || process.env.path || ''; + if (!pathValue) return false; + + const extensions = + process.platform === 'win32' + ? Array.from( + new Set([ + '', + '.cmd', + '.bat', + '.exe', + '.ps1', + ...(process.env.PATHEXT || '') + .split(';') + .map((ext) => ext.trim().toLowerCase()) + .filter(Boolean) + .map((ext) => (ext.startsWith('.') ? ext : `.${ext}`)), + ]), + ) + : ['']; + + for (const dir of pathValue.split(path.delimiter).filter(Boolean)) { + for (const ext of extensions) { + try { + const candidate = path.join(dir, `${command}${ext}`); + if (!fs.statSync(candidate).isFile()) continue; + if (process.platform !== 'win32') fs.accessSync(candidate, fs.constants.X_OK); + return true; + } catch { + /* try next candidate */ + } + } + } + return false; +} + +function buildAnalyzeCommand(hadEmbeddings) { + const analyzeBin = commandExistsOnPath('gitnexus') ? 'gitnexus' : 'npx gitnexus'; + return `${analyzeBin} analyze${hadEmbeddings ? ' --embeddings' : ''}`; +} + /** * Spawn a gitnexus CLI command synchronously. * Detects binary on PATH once, then runs exactly once. @@ -345,7 +387,7 @@ function handlePostToolUse(input) { // If HEAD matches last indexed commit, no reindex needed if (currentHead && currentHead === lastCommit) return; - const analyzeCmd = `npx gitnexus analyze${hadEmbeddings ? ' --embeddings' : ''}`; + const analyzeCmd = buildAnalyzeCommand(hadEmbeddings); sendHookResponse( 'PostToolUse', `GitNexus index is stale (last indexed: ${lastCommit ? lastCommit.slice(0, 7) : 'never'}). ` + diff --git a/gitnexus/hooks/antigravity/gitnexus-antigravity-hook.cjs b/gitnexus/hooks/antigravity/gitnexus-antigravity-hook.cjs index 4bd807631d..681cfbd4aa 100755 --- a/gitnexus/hooks/antigravity/gitnexus-antigravity-hook.cjs +++ b/gitnexus/hooks/antigravity/gitnexus-antigravity-hook.cjs @@ -207,6 +207,48 @@ function runGitNexusCli(cliPath, args, cwd, timeout) { }); } +function commandExistsOnPath(command) { + const pathValue = process.env.PATH || process.env.Path || process.env.path || ''; + if (!pathValue) return false; + + const extensions = + process.platform === 'win32' + ? Array.from( + new Set([ + '', + '.cmd', + '.bat', + '.exe', + '.ps1', + ...(process.env.PATHEXT || '') + .split(';') + .map((ext) => ext.trim().toLowerCase()) + .filter(Boolean) + .map((ext) => (ext.startsWith('.') ? ext : `.${ext}`)), + ]), + ) + : ['']; + + for (const dir of pathValue.split(path.delimiter).filter(Boolean)) { + for (const ext of extensions) { + try { + const candidate = path.join(dir, `${command}${ext}`); + if (!fs.statSync(candidate).isFile()) continue; + if (process.platform !== 'win32') fs.accessSync(candidate, fs.constants.X_OK); + return true; + } catch { + /* try next candidate */ + } + } + } + return false; +} + +function buildAnalyzeCommand(hadEmbeddings) { + const analyzeBin = commandExistsOnPath('gitnexus') ? 'gitnexus' : 'npx gitnexus'; + return `${analyzeBin} analyze${hadEmbeddings ? ' --embeddings' : ''}`; +} + function writeAdditionalContext(text) { process.stdout.write( JSON.stringify({ @@ -315,7 +357,7 @@ function buildStaleIndexHint(gitNexusDir, cwd) { if (currentHead === lastCommit) return ''; - const analyzeCmd = `npx gitnexus analyze${hadEmbeddings ? ' --embeddings' : ''}`; + const analyzeCmd = buildAnalyzeCommand(hadEmbeddings); return ( `[GitNexus] index is stale (last indexed: ${lastCommit ? lastCommit.slice(0, 7) : 'never'}). ` + `Run \`${analyzeCmd}\` to refresh the knowledge graph.` diff --git a/gitnexus/hooks/claude/gitnexus-hook.cjs b/gitnexus/hooks/claude/gitnexus-hook.cjs index 9793bd7bc8..1270f3ee67 100755 --- a/gitnexus/hooks/claude/gitnexus-hook.cjs +++ b/gitnexus/hooks/claude/gitnexus-hook.cjs @@ -207,6 +207,48 @@ function resolveCliPath() { return cliPath; } +function commandExistsOnPath(command) { + const pathValue = process.env.PATH || process.env.Path || process.env.path || ''; + if (!pathValue) return false; + + const extensions = + process.platform === 'win32' + ? Array.from( + new Set([ + '', + '.cmd', + '.bat', + '.exe', + '.ps1', + ...(process.env.PATHEXT || '') + .split(';') + .map((ext) => ext.trim().toLowerCase()) + .filter(Boolean) + .map((ext) => (ext.startsWith('.') ? ext : `.${ext}`)), + ]), + ) + : ['']; + + for (const dir of pathValue.split(path.delimiter).filter(Boolean)) { + for (const ext of extensions) { + try { + const candidate = path.join(dir, `${command}${ext}`); + if (!fs.statSync(candidate).isFile()) continue; + if (process.platform !== 'win32') fs.accessSync(candidate, fs.constants.X_OK); + return true; + } catch { + /* try next candidate */ + } + } + } + return false; +} + +function buildAnalyzeCommand(hadEmbeddings) { + const analyzeBin = commandExistsOnPath('gitnexus') ? 'gitnexus' : 'npx gitnexus'; + return `${analyzeBin} analyze${hadEmbeddings ? ' --embeddings' : ''}`; +} + /** * Spawn a gitnexus CLI command synchronously. * Returns the stderr output (KuzuDB captures stdout at OS level). @@ -340,7 +382,7 @@ function handlePostToolUse(input) { // If HEAD matches last indexed commit, no reindex needed if (currentHead && currentHead === lastCommit) return; - const analyzeCmd = `npx gitnexus analyze${hadEmbeddings ? ' --embeddings' : ''}`; + const analyzeCmd = buildAnalyzeCommand(hadEmbeddings); sendHookResponse( 'PostToolUse', `GitNexus index is stale (last indexed: ${lastCommit ? lastCommit.slice(0, 7) : 'never'}). ` + diff --git a/gitnexus/test/integration/antigravity-hook-e2e.test.ts b/gitnexus/test/integration/antigravity-hook-e2e.test.ts index 617214c0c1..8d2bbb1a11 100644 --- a/gitnexus/test/integration/antigravity-hook-e2e.test.ts +++ b/gitnexus/test/integration/antigravity-hook-e2e.test.ts @@ -22,7 +22,13 @@ import fsp from 'fs/promises'; import path from 'path'; import { cleanupTempDir, cleanupTempDirSync } from '../helpers/test-db.js'; import os from 'os'; -import { runHook, parseHookOutput } from '../utils/hook-test-helpers.js'; +import { + createGitNexusPathEntry, + envWithPath, + parseHookOutput, + pathWithoutGitNexus, + runHook, +} from '../utils/hook-test-helpers.js'; import { setupCommand } from '../../src/cli/setup.js'; let tempHome: string; @@ -97,13 +103,20 @@ describe('antigravity hook adapter e2e', () => { JSON.stringify({ lastCommit: 'a'.repeat(40), stats: {} }), ); - const result = runHook(installedHook, { - hook_event_name: 'AfterTool', - tool_name: 'run_shell_command', - tool_input: { command: 'git commit -m "test"' }, - tool_response: { llmContent: '[committed]' }, - cwd: tmpDir, - }); + const result = runHook( + installedHook, + { + hook_event_name: 'AfterTool', + tool_name: 'run_shell_command', + tool_input: { command: 'git commit -m "test"' }, + tool_response: { llmContent: '[committed]' }, + cwd: tmpDir, + }, + undefined, + { + env: envWithPath(pathWithoutGitNexus()), + }, + ); const output = parseHookOutput(result.stdout); expect(output).not.toBeNull(); @@ -116,6 +129,38 @@ describe('antigravity hook adapter e2e', () => { expect(result.stderr).toContain('[GitNexus] index is stale'); }); + it('suggests direct gitnexus analyze when gitnexus is on PATH', () => { + fs.writeFileSync( + path.join(gitNexusDir, 'meta.json'), + JSON.stringify({ lastCommit: 'a'.repeat(39) + 'b', stats: {} }), + ); + const gitNexusPath = createGitNexusPathEntry(); + + try { + const result = runHook( + installedHook, + { + hook_event_name: 'AfterTool', + tool_name: 'run_shell_command', + tool_input: { command: 'git commit -m "test"' }, + tool_response: { llmContent: '[committed]' }, + cwd: tmpDir, + }, + undefined, + { + env: envWithPath(gitNexusPath.pathValue), + }, + ); + + const output = parseHookOutput(result.stdout); + expect(output).not.toBeNull(); + expect(output!.additionalContext).toContain('Run `gitnexus analyze`'); + expect(output!.additionalContext).not.toContain('npx gitnexus analyze'); + } finally { + gitNexusPath.cleanup(); + } + }); + it('stays silent when meta.json lastCommit matches HEAD', () => { const head = spawnSync('git', ['rev-parse', 'HEAD'], { cwd: tmpDir, diff --git a/gitnexus/test/integration/hooks-e2e.test.ts b/gitnexus/test/integration/hooks-e2e.test.ts index 6d3b79172d..17473973f2 100644 --- a/gitnexus/test/integration/hooks-e2e.test.ts +++ b/gitnexus/test/integration/hooks-e2e.test.ts @@ -10,7 +10,13 @@ import { spawnSync } from 'child_process'; import fs from 'fs'; import path from 'path'; import os from 'os'; -import { runHook, parseHookOutput } from '../utils/hook-test-helpers.js'; +import { + createGitNexusPathEntry, + envWithPath, + parseHookOutput, + pathWithoutGitNexus, + runHook, +} from '../utils/hook-test-helpers.js'; // ─── Paths to both hook variants ──────────────────────────────────── @@ -66,13 +72,20 @@ describe.each(HOOKS)('hooks e2e ($name)', ({ name, path: hookPath }) => { JSON.stringify({ lastCommit: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', stats: {} }), ); - const result = runHook(hookPath, { - hook_event_name: 'PostToolUse', - tool_name: 'Bash', - tool_input: { command: 'git commit -m "test"' }, - tool_output: { exit_code: 0 }, - cwd: tmpDir, - }); + const result = runHook( + hookPath, + { + hook_event_name: 'PostToolUse', + tool_name: 'Bash', + tool_input: { command: 'git commit -m "test"' }, + tool_output: { exit_code: 0 }, + cwd: tmpDir, + }, + undefined, + { + env: envWithPath(pathWithoutGitNexus()), + }, + ); const output = parseHookOutput(result.stdout); expect(output).not.toBeNull(); @@ -80,6 +93,41 @@ describe.each(HOOKS)('hooks e2e ($name)', ({ name, path: hookPath }) => { expect(output!.additionalContext).toContain('npx gitnexus analyze'); }); + it('suggests direct gitnexus analyze when gitnexus is on PATH', () => { + fs.writeFileSync( + path.join(gitNexusDir, 'meta.json'), + JSON.stringify({ + lastCommit: 'abababababababababababababababababababab', + stats: {}, + }), + ); + const gitNexusPath = createGitNexusPathEntry(); + + try { + const result = runHook( + hookPath, + { + hook_event_name: 'PostToolUse', + tool_name: 'Bash', + tool_input: { command: 'git commit -m "test"' }, + tool_output: { exit_code: 0 }, + cwd: tmpDir, + }, + undefined, + { + env: envWithPath(gitNexusPath.pathValue), + }, + ); + + const output = parseHookOutput(result.stdout); + expect(output).not.toBeNull(); + expect(output!.additionalContext).toContain('Run `gitnexus analyze`'); + expect(output!.additionalContext).not.toContain('npx gitnexus analyze'); + } finally { + gitNexusPath.cleanup(); + } + }); + it('stays silent when meta.json lastCommit matches HEAD', () => { // Get current HEAD const headResult = spawnSync('git', ['rev-parse', 'HEAD'], { diff --git a/gitnexus/test/utils/hook-test-helpers.ts b/gitnexus/test/utils/hook-test-helpers.ts index 3f519bc81e..86960d192d 100644 --- a/gitnexus/test/utils/hook-test-helpers.ts +++ b/gitnexus/test/utils/hook-test-helpers.ts @@ -2,6 +2,9 @@ * Shared helpers for hook test files (unit + integration). */ import { spawnSync } from 'child_process'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; export function runHook( hookPath: string, @@ -35,3 +38,48 @@ export function parseHookOutput( return null; } } + +function gitNexusLauncherNames(): string[] { + return process.platform === 'win32' + ? ['gitnexus', 'gitnexus.cmd', 'gitnexus.bat', 'gitnexus.exe', 'gitnexus.ps1'] + : ['gitnexus']; +} + +export function pathWithoutGitNexus( + pathValue = process.env.PATH || process.env.Path || process.env.path || '', +): string { + return pathValue + .split(path.delimiter) + .filter((dir) => { + if (!dir) return false; + return !gitNexusLauncherNames().some((name) => fs.existsSync(path.join(dir, name))); + }) + .join(path.delimiter); +} + +export function envWithPath(pathValue: string): NodeJS.ProcessEnv { + const env = { ...process.env }; + for (const key of Object.keys(env)) { + if (key.toLowerCase() === 'path') delete env[key]; + } + env.PATH = pathValue; + return env; +} + +export function createGitNexusPathEntry(): { + pathValue: string; + cleanup: () => void; +} { + const binDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-path-')); + const launcher = path.join(binDir, process.platform === 'win32' ? 'gitnexus.cmd' : 'gitnexus'); + fs.writeFileSync( + launcher, + process.platform === 'win32' ? '@echo off\r\nexit /b 0\r\n' : '#!/bin/sh\nexit 0\n', + ); + if (process.platform !== 'win32') fs.chmodSync(launcher, 0o755); + + return { + pathValue: [binDir, pathWithoutGitNexus()].filter(Boolean).join(path.delimiter), + cleanup: () => fs.rmSync(binDir, { recursive: true, force: true }), + }; +}