Skip to content
Closed
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
44 changes: 43 additions & 1 deletion gitnexus-claude-plugin/hooks/gitnexus-hook.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 [code-read] — This file now has two PATH strategies: buildAnalyzeCommand uses the new fs PATH walk (L188–227), while runGitNexusCli still uses where/which + hardcoded gitnexus.cmd (L250–271). They can disagree on edge cases (PATHEXT, non-spawnable shims).

Fix: Share one launcher-resolution helper for both the stale hint and CLI spawn paths.

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.
Expand Down Expand Up @@ -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'}). ` +
Expand Down
44 changes: 43 additions & 1 deletion gitnexus/hooks/antigravity/gitnexus-antigravity-hook.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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.`
Expand Down
44 changes: 43 additions & 1 deletion gitnexus/hooks/claude/gitnexus-hook.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 [code-read].ps1 is treated as a positive PATH hit, but setup.ts resolveGitnexusBin() explicitly rejects .ps1-only installs and falls back to npx (setup.test.ts:302). On Windows npm-11 layouts with only gitnexus.ps1, this hint says `gitnexus analyze` while MCP setup still uses npx.

Fix: On win32, accept .cmd|.bat|.exe only (mirror setup.ts:69); drop .ps1 from positive detection. Same block is copy-pasted at gitnexus-antigravity-hook.cjs:222 and gitnexus-claude-plugin/hooks/gitnexus-hook.js:200.

...(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).
Expand Down Expand Up @@ -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'}). ` +
Expand Down
61 changes: 53 additions & 8 deletions gitnexus/test/integration/antigravity-hook-e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand All @@ -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,
Expand Down
64 changes: 56 additions & 8 deletions gitnexus/test/integration/hooks-e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ────────────────────────────────────

Expand Down Expand Up @@ -66,20 +72,62 @@ 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();
expect(output!.additionalContext).toContain('stale');
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'], {
Expand Down
48 changes: 48 additions & 0 deletions gitnexus/test/utils/hook-test-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 }),
};
}