mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-05 02:43:32 +00:00
Merge branch 'main' into feat/Desktop-app
This commit is contained in:
commit
fc5b571928
11 changed files with 1248 additions and 6 deletions
14
AGENTS.md
14
AGENTS.md
|
|
@ -181,6 +181,20 @@ Check `.gitnexus/meta.json` `stats.embeddings` (0 = none). A plain `analyze` no
|
|||
| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` |
|
||||
| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` |
|
||||
|
||||
## Hook env knobs
|
||||
|
||||
The Claude Code hook (`gitnexus/hooks/claude/gitnexus-hook.cjs` and the mirrored plugin copy under `gitnexus-claude-plugin/hooks/`) honours these env vars. Defaults work for normal installations; set them only to override resolution. All path overrides ignore values that do not exist on disk and fall through to the standard resolution chain.
|
||||
|
||||
| Env var | Type | Default | Purpose |
|
||||
|---------|------|---------|---------|
|
||||
| `GITNEXUS_HOOK_CLI_PATH` | path | resolved via package layout / `require.resolve` | Override path to the `gitnexus` CLI entry the hook spawns for `augment`. |
|
||||
| `GITNEXUS_HOOK_LSOF_PATH` | path | `lsof` on `PATH` (with `/usr/bin/lsof`, `/usr/sbin/lsof`, `/sbin/lsof` fallbacks) | Override POSIX `lsof` location for the DB-lock probe. |
|
||||
| `GITNEXUS_HOOK_PS_PATH` | path | `ps` on `PATH` (with `/bin/ps`, `/usr/bin/ps` fallbacks) | Override POSIX `ps` location. |
|
||||
| `GITNEXUS_HOOK_POWERSHELL_PATH` | path | `%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe` (then `SysWOW64`, then `powershell.exe` on `PATH`) | Override Windows PowerShell location used by the Restart-Manager probe. |
|
||||
| `GITNEXUS_HOOK_LINUX_PROC_BUDGET_MS` | integer ms | `1200` | Max wall-clock for the Linux `/proc` fd scan before bailing out to the `lsof` fallback. |
|
||||
| `GITNEXUS_HOOK_RM_TARGET` | path | derived | Restart-Manager target file (the LadybugDB path under `.gitnexus/`). Set internally by the hook; rarely overridden manually. |
|
||||
| `GITNEXUS_DEBUG` | boolean (`1`/`true`) | unset | Verbose stderr from the hook: prints discarded augment-stderr prefixes and one-shot `.ps1` load-failure warnings. |
|
||||
|
||||
<!-- gitnexus:end -->
|
||||
|
||||
## Repo reference
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ const fs = require('fs');
|
|||
const path = require('path');
|
||||
const { spawnSync } = require('child_process');
|
||||
const { acquireHookSlot } = require('./hook-lock.js');
|
||||
const { hasGitNexusDbLockedByGitNexusServer } = require('./hook-db-lock-probe.cjs');
|
||||
|
||||
/**
|
||||
* Read JSON input from stdin synchronously.
|
||||
|
|
@ -103,6 +104,28 @@ function findGitNexusDir(startDir) {
|
|||
return null;
|
||||
}
|
||||
|
||||
function hasGitNexusServerOwner(gitNexusDir) {
|
||||
return hasGitNexusDbLockedByGitNexusServer(path.join(gitNexusDir, 'lbug'), process.pid);
|
||||
}
|
||||
|
||||
function extractAugmentContext(stderr) {
|
||||
const output = (stderr || '').trim();
|
||||
const marker = output.indexOf('[GitNexus]');
|
||||
const debug = process.env.GITNEXUS_DEBUG === '1' || process.env.GITNEXUS_DEBUG === 'true';
|
||||
if (debug && output.length > 0) {
|
||||
// Emit the FULL discarded prefix (everything before the marker, or all of
|
||||
// it when no marker is present) so suppressed diagnostics — LadybugDB lock
|
||||
// warnings, parser errors, etc. — remain recoverable on the hook's own
|
||||
// stderr. The untruncated payload lets operators see exactly what was
|
||||
// filtered out instead of a 180-char JSON-quoted preview.
|
||||
const discarded = marker === -1 ? output : output.slice(0, marker).trim();
|
||||
if (discarded.length > 0) {
|
||||
process.stderr.write(`[GitNexus hook] augment stderr discarded prefix:\n${discarded}\n`);
|
||||
}
|
||||
}
|
||||
return marker === -1 ? '' : output.slice(marker).trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract search pattern from tool input.
|
||||
*/
|
||||
|
|
@ -170,6 +193,15 @@ function extractPattern(toolName, toolInput) {
|
|||
*/
|
||||
function runGitNexusCli(args, cwd, timeout) {
|
||||
const isWin = process.platform === 'win32';
|
||||
const hookCli = process.env.GITNEXUS_HOOK_CLI_PATH;
|
||||
if (hookCli !== undefined && String(hookCli).trim() && fs.existsSync(String(hookCli))) {
|
||||
return spawnSync(process.execPath, [String(hookCli), ...args], {
|
||||
encoding: 'utf-8',
|
||||
timeout,
|
||||
cwd,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
}
|
||||
|
||||
// Detect whether 'gitnexus' is on PATH (cheap check, no execution)
|
||||
let useDirectBinary = false;
|
||||
|
|
@ -228,6 +260,10 @@ function handlePreToolUse(input) {
|
|||
|
||||
const pattern = extractPattern(toolName, toolInput);
|
||||
if (!pattern || pattern.length < 3) return;
|
||||
if (hasGitNexusServerOwner(gitNexusDir)) {
|
||||
process.stderr.write('[GitNexus] augment skipped: MCP server owns DB\n');
|
||||
return;
|
||||
}
|
||||
|
||||
const release = acquireHookSlot(gitNexusDir);
|
||||
if (!release) return;
|
||||
|
|
@ -236,7 +272,7 @@ function handlePreToolUse(input) {
|
|||
try {
|
||||
const child = runGitNexusCli(['augment', '--', pattern], cwd, 7000);
|
||||
if (!child.error && child.status === 0) {
|
||||
result = child.stderr || '';
|
||||
result = extractAugmentContext(child.stderr || '');
|
||||
}
|
||||
} catch {
|
||||
/* graceful failure */
|
||||
|
|
@ -244,8 +280,8 @@ function handlePreToolUse(input) {
|
|||
release();
|
||||
}
|
||||
|
||||
if (result && result.trim()) {
|
||||
sendHookResponse('PreToolUse', result.trim());
|
||||
if (result) {
|
||||
sendHookResponse('PreToolUse', result);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
238
gitnexus-claude-plugin/hooks/hook-db-lock-probe.cjs
Normal file
238
gitnexus-claude-plugin/hooks/hook-db-lock-probe.cjs
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
/**
|
||||
* Cross-platform best-effort probe: does another process hold dbPath open
|
||||
* with a command line that looks like a GitNexus MCP/serve server?
|
||||
*
|
||||
* Backends (no user-installed Sysinternals):
|
||||
* - Linux: scan procfs under /proc (per-PID fd entries) via stat(2) (dev+inode); works without lsof;
|
||||
* optional lsof fallback when proc scan finds nothing.
|
||||
* - macOS / *BSD / etc.: trusted lsof + ps (absolute paths first).
|
||||
* - Windows: Restart Manager (rstrtmgr) via bundled PowerShell script +
|
||||
* Win32_Process for command lines; trusted powershell.exe under %SystemRoot%.
|
||||
*
|
||||
* Fail-open on most errors; fail-closed only on lsof ETIMEDOUT (Unix) or
|
||||
* PowerShell ETIMEDOUT (Windows), matching the hook contract.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
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 resolveHookBinary(tool) {
|
||||
const envKey = tool === 'lsof' ? 'GITNEXUS_HOOK_LSOF_PATH' : 'GITNEXUS_HOOK_PS_PATH';
|
||||
const fromEnv = process.env[envKey];
|
||||
if (fromEnv && String(fromEnv).trim() && fs.existsSync(String(fromEnv))) {
|
||||
return String(fromEnv);
|
||||
}
|
||||
const candidates =
|
||||
tool === 'lsof'
|
||||
? ['/usr/bin/lsof', '/usr/sbin/lsof', '/sbin/lsof', tool]
|
||||
: ['/bin/ps', '/usr/bin/ps', tool];
|
||||
for (const candidate of candidates) {
|
||||
if (candidate === tool) return tool;
|
||||
try {
|
||||
if (fs.existsSync(candidate)) return candidate;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
return tool;
|
||||
}
|
||||
|
||||
function resolveWindowsPowerShellPath() {
|
||||
const fromEnv = process.env.GITNEXUS_HOOK_POWERSHELL_PATH;
|
||||
if (fromEnv && String(fromEnv).trim() && fs.existsSync(String(fromEnv).trim())) {
|
||||
return String(fromEnv).trim();
|
||||
}
|
||||
const root = process.env.SystemRoot || 'C:\\Windows';
|
||||
const ps = path.join(root, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
|
||||
if (fs.existsSync(ps)) return ps;
|
||||
const psWow = path.join(root, 'SysWOW64', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
|
||||
if (fs.existsSync(psWow)) return psWow;
|
||||
return 'powershell.exe';
|
||||
}
|
||||
|
||||
// Sentinel:
|
||||
// undefined = not loaded yet (try the read)
|
||||
// string = encoded PowerShell command (successful load)
|
||||
// null = load attempted and failed (do not retry; warning already emitted)
|
||||
let windowsRmListPsEncodedCommandCache;
|
||||
let windowsRmListPsLoadFailureWarned = false;
|
||||
function getWindowsRmListEncodedCommand() {
|
||||
if (windowsRmListPsEncodedCommandCache !== undefined) {
|
||||
return windowsRmListPsEncodedCommandCache;
|
||||
}
|
||||
try {
|
||||
const ps1Path = path.join(__dirname, 'win-rm-list-json.ps1');
|
||||
const src = fs
|
||||
.readFileSync(ps1Path, 'utf8')
|
||||
.replace(/^\uFEFF/, '')
|
||||
.replace(/\r\n/g, '\n');
|
||||
windowsRmListPsEncodedCommandCache = Buffer.from(src, 'utf16le').toString('base64');
|
||||
} catch (err) {
|
||||
windowsRmListPsEncodedCommandCache = null;
|
||||
if (
|
||||
!windowsRmListPsLoadFailureWarned &&
|
||||
(process.env.GITNEXUS_DEBUG === '1' || process.env.GITNEXUS_DEBUG === 'true')
|
||||
) {
|
||||
windowsRmListPsLoadFailureWarned = true;
|
||||
const msg = err && err.message ? String(err.message).slice(0, 200) : 'unknown';
|
||||
process.stderr.write(`[GitNexus hook] win-rm-list-json.ps1 load failed: ${msg}\n`);
|
||||
}
|
||||
}
|
||||
return windowsRmListPsEncodedCommandCache;
|
||||
}
|
||||
|
||||
function hasGitNexusServerOwnerWindows(dbPathAbs, myPid) {
|
||||
const encoded = getWindowsRmListEncodedCommand();
|
||||
if (!encoded) return false;
|
||||
const psExe = resolveWindowsPowerShellPath();
|
||||
const r = spawnSync(
|
||||
psExe,
|
||||
[
|
||||
'-NoProfile',
|
||||
'-NonInteractive',
|
||||
'-ExecutionPolicy',
|
||||
'Bypass',
|
||||
'-STA',
|
||||
'-EncodedCommand',
|
||||
encoded,
|
||||
],
|
||||
{
|
||||
encoding: 'utf-8',
|
||||
timeout: 6000,
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
env: { ...process.env, GITNEXUS_HOOK_RM_TARGET: dbPathAbs },
|
||||
},
|
||||
);
|
||||
// ETIMEDOUT means the PowerShell probe didn't return in time; treat as 'unresponsive process holds DB' → fail-closed (skip augment).
|
||||
if (r.error) return r.error.code === 'ETIMEDOUT';
|
||||
if (r.status !== 0) return false;
|
||||
let rows;
|
||||
try {
|
||||
rows = JSON.parse(String(r.stdout || '').trim() || '[]');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (!Array.isArray(rows)) return false;
|
||||
for (const row of rows) {
|
||||
const procId = Number(row.pid);
|
||||
const cmd = String(row.cmd || '');
|
||||
if (!Number.isFinite(procId) || procId === myPid) continue;
|
||||
if (isGitNexusServerCommand(cmd)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function readLinuxCmdline(pidStr) {
|
||||
try {
|
||||
return fs.readFileSync(`/proc/${pidStr}/cmdline`, 'utf8').replace(/\0+/g, ' ').trim();
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function linuxProcScanFindGitNexusServer(dbPathAbs, myPid) {
|
||||
const raw = process.env.GITNEXUS_HOOK_LINUX_PROC_BUDGET_MS;
|
||||
const budget = Number(raw && String(raw).trim()) ? Number.parseInt(String(raw), 10) : 1200;
|
||||
const start = Date.now();
|
||||
let targetStat;
|
||||
try {
|
||||
targetStat = fs.statSync(dbPathAbs);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
let procEntries;
|
||||
try {
|
||||
procEntries = fs.readdirSync('/proc', { withFileTypes: true });
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
for (const ent of procEntries) {
|
||||
if (Date.now() - start > budget) return false;
|
||||
if (!ent.isDirectory() || !/^\d+$/.test(ent.name)) continue;
|
||||
const pid = Number.parseInt(ent.name, 10);
|
||||
if (!Number.isFinite(pid) || pid === myPid) continue;
|
||||
const fdDir = path.join('/proc', ent.name, 'fd');
|
||||
let fds;
|
||||
try {
|
||||
fds = fs.readdirSync(fdDir);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
let holds = false;
|
||||
for (const fd of fds) {
|
||||
if (Date.now() - start > budget) return false;
|
||||
try {
|
||||
const st = fs.statSync(path.join(fdDir, fd));
|
||||
if (st.dev === targetStat.dev && st.ino === targetStat.ino) {
|
||||
holds = true;
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
if (!holds) continue;
|
||||
if (isGitNexusServerCommand(readLinuxCmdline(ent.name))) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function unixLsofPsFindGitNexusServer(dbPathAbs, myPid) {
|
||||
const lsofPath = resolveHookBinary('lsof');
|
||||
const lsof = spawnSync(lsofPath, ['-nP', '-t', '--', dbPathAbs], {
|
||||
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);
|
||||
const psPath = resolveHookBinary('ps');
|
||||
for (const pid of pids) {
|
||||
if (Number(pid) === myPid) continue;
|
||||
const ps = spawnSync(psPath, ['-p', pid, '-o', 'command='], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 500,
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
});
|
||||
if (ps.error) {
|
||||
if (ps.error.code === 'ETIMEDOUT') return true;
|
||||
continue;
|
||||
}
|
||||
if (isGitNexusServerCommand(ps.stdout || '')) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} dbPath Absolute or relative path to the DB file (e.g. .../lbug).
|
||||
* @param {number} myPid Current process PID (hook runner), excluded from matches.
|
||||
*/
|
||||
function hasGitNexusDbLockedByGitNexusServer(dbPath, myPid) {
|
||||
if (!fs.existsSync(dbPath)) return false;
|
||||
const dbPathAbs = path.resolve(dbPath);
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
return hasGitNexusServerOwnerWindows(dbPathAbs, myPid);
|
||||
}
|
||||
|
||||
if (process.platform === 'linux') {
|
||||
if (linuxProcScanFindGitNexusServer(dbPathAbs, myPid)) return true;
|
||||
return unixLsofPsFindGitNexusServer(dbPathAbs, myPid);
|
||||
}
|
||||
|
||||
return unixLsofPsFindGitNexusServer(dbPathAbs, myPid);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
hasGitNexusDbLockedByGitNexusServer,
|
||||
};
|
||||
76
gitnexus-claude-plugin/hooks/win-rm-list-json.ps1
Normal file
76
gitnexus-claude-plugin/hooks/win-rm-list-json.ps1
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
$ErrorActionPreference = 'Stop'
|
||||
$target = $env:GITNEXUS_HOOK_RM_TARGET
|
||||
if ([string]::IsNullOrWhiteSpace($target)) { Write-Output '[]'; exit 0 }
|
||||
$target = (Resolve-Path -LiteralPath $target).ProviderPath
|
||||
|
||||
if (-not ([Management.Automation.PSTypeName]'GitNexusHookRm.Native').Type) {
|
||||
Add-Type @'
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
namespace GitNexusHookRm {
|
||||
public static class Native {
|
||||
public const int ErrorMoreData = 234;
|
||||
[StructLayout(LayoutKind.Sequential, Pack = 4)]
|
||||
public struct RM_UNIQUE_PROCESS {
|
||||
public int dwProcessId;
|
||||
public long ProcessStartTime;
|
||||
}
|
||||
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
||||
public struct RM_PROCESS_INFO {
|
||||
public RM_UNIQUE_PROCESS Process;
|
||||
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)]
|
||||
public string strAppName;
|
||||
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 64)]
|
||||
public string strServiceShortName;
|
||||
public uint ApplicationType;
|
||||
public uint AppStatus;
|
||||
public uint TSSessionId;
|
||||
public uint bRestartable;
|
||||
}
|
||||
[DllImport("rstrtmgr.dll", CharSet = CharSet.Unicode)]
|
||||
public static extern int RmStartSession(out uint pSessionHandle, uint dwSessionFlags, string strSessionKey);
|
||||
[DllImport("rstrtmgr.dll", CharSet = CharSet.Unicode)]
|
||||
public static extern int RmRegisterResources(uint pSessionHandle, uint nFiles, string[] rgsFileNames, uint nApplications, IntPtr rgApplications, uint nServices, string[] rgsServiceNames);
|
||||
[DllImport("rstrtmgr.dll")]
|
||||
public static extern int RmGetList(uint dwSessionHandle, out uint pnProcInfoNeeded, ref uint pnProcInfo, [In, Out] RM_PROCESS_INFO[] rgAffectedApps, ref uint lpdwRebootReasons);
|
||||
[DllImport("rstrtmgr.dll")]
|
||||
public static extern int RmEndSession(uint pSessionHandle);
|
||||
}
|
||||
}
|
||||
'@
|
||||
}
|
||||
|
||||
$h = [uint32]0
|
||||
$key = [guid]::NewGuid().ToString('N')
|
||||
$rmErr = [GitNexusHookRm.Native]::RmStartSession([ref]$h, 0, $key)
|
||||
if ($rmErr -ne 0) { Write-Output '[]'; exit 0 }
|
||||
$files = @($target)
|
||||
$err = [GitNexusHookRm.Native]::RmRegisterResources($h, 1, $files, 0, [IntPtr]::Zero, 0, $null)
|
||||
if ($err -ne 0) {
|
||||
[void][GitNexusHookRm.Native]::RmEndSession($h)
|
||||
Write-Output '[]'
|
||||
exit 0
|
||||
}
|
||||
$need = [uint32]0
|
||||
$n = [uint32]0
|
||||
$reboot = [uint32]0
|
||||
$err = [GitNexusHookRm.Native]::RmGetList($h, [ref]$need, [ref]$n, $null, [ref]$reboot)
|
||||
if ($err -ne [GitNexusHookRm.Native]::ErrorMoreData) {
|
||||
[void][GitNexusHookRm.Native]::RmEndSession($h)
|
||||
Write-Output '[]'
|
||||
exit 0
|
||||
}
|
||||
$n = $need
|
||||
$buf = New-Object GitNexusHookRm.Native+RM_PROCESS_INFO[] ([int]$n)
|
||||
$err = [GitNexusHookRm.Native]::RmGetList($h, [ref]$need, [ref]$n, $buf, [ref]$reboot)
|
||||
[void][GitNexusHookRm.Native]::RmEndSession($h)
|
||||
if ($err -ne 0) { Write-Output '[]'; exit 0 }
|
||||
|
||||
$out = @()
|
||||
for ($i = 0; $i -lt [int]$n; $i++) {
|
||||
$procId = $buf[$i].Process.dwProcessId
|
||||
$p = Get-CimInstance -ClassName Win32_Process -Filter "ProcessId=$procId" -ErrorAction SilentlyContinue
|
||||
$cmd = if ($p) { $p.CommandLine } else { '' }
|
||||
$out += [PSCustomObject]@{ pid = [int]$procId; cmd = $cmd }
|
||||
}
|
||||
ConvertTo-Json -InputObject @($out) -Compress
|
||||
|
|
@ -15,6 +15,7 @@ const fs = require('fs');
|
|||
const path = require('path');
|
||||
const { spawnSync } = require('child_process');
|
||||
const { acquireHookSlot } = require('./hook-lock.cjs');
|
||||
const { hasGitNexusDbLockedByGitNexusServer } = require('./hook-db-lock-probe.cjs');
|
||||
|
||||
/**
|
||||
* Read JSON input from stdin synchronously.
|
||||
|
|
@ -103,6 +104,28 @@ function findGitNexusDir(startDir) {
|
|||
return null;
|
||||
}
|
||||
|
||||
function hasGitNexusServerOwner(gitNexusDir) {
|
||||
return hasGitNexusDbLockedByGitNexusServer(path.join(gitNexusDir, 'lbug'), process.pid);
|
||||
}
|
||||
|
||||
function extractAugmentContext(stderr) {
|
||||
const output = (stderr || '').trim();
|
||||
const marker = output.indexOf('[GitNexus]');
|
||||
const debug = process.env.GITNEXUS_DEBUG === '1' || process.env.GITNEXUS_DEBUG === 'true';
|
||||
if (debug && output.length > 0) {
|
||||
// Emit the FULL discarded prefix (everything before the marker, or all of
|
||||
// it when no marker is present) so suppressed diagnostics — KuzuDB lock
|
||||
// warnings, parser errors, etc. — remain recoverable on the hook's own
|
||||
// stderr. The untruncated payload lets operators see exactly what was
|
||||
// filtered out instead of a 180-char JSON-quoted preview.
|
||||
const discarded = marker === -1 ? output : output.slice(0, marker).trim();
|
||||
if (discarded.length > 0) {
|
||||
process.stderr.write(`[GitNexus hook] augment stderr discarded prefix:\n${discarded}\n`);
|
||||
}
|
||||
}
|
||||
return marker === -1 ? '' : output.slice(marker).trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract search pattern from tool input.
|
||||
*/
|
||||
|
|
@ -168,6 +191,10 @@ function extractPattern(toolName, toolInput) {
|
|||
* 3. Fall back to npx (returns empty string)
|
||||
*/
|
||||
function resolveCliPath() {
|
||||
const fromEnv = process.env.GITNEXUS_HOOK_CLI_PATH;
|
||||
if (fromEnv !== undefined && String(fromEnv).trim() && fs.existsSync(String(fromEnv))) {
|
||||
return String(fromEnv);
|
||||
}
|
||||
let cliPath = path.resolve(__dirname, '..', '..', 'dist', 'cli', 'index.js');
|
||||
if (!fs.existsSync(cliPath)) {
|
||||
try {
|
||||
|
|
@ -218,6 +245,10 @@ function handlePreToolUse(input) {
|
|||
|
||||
const pattern = extractPattern(toolName, toolInput);
|
||||
if (!pattern || pattern.length < 3) return;
|
||||
if (hasGitNexusServerOwner(gitNexusDir)) {
|
||||
process.stderr.write('[GitNexus] augment skipped: MCP server owns DB\n');
|
||||
return;
|
||||
}
|
||||
|
||||
const release = acquireHookSlot(gitNexusDir);
|
||||
if (!release) return;
|
||||
|
|
@ -227,7 +258,7 @@ function handlePreToolUse(input) {
|
|||
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 */
|
||||
|
|
@ -235,8 +266,8 @@ function handlePreToolUse(input) {
|
|||
release();
|
||||
}
|
||||
|
||||
if (result && result.trim()) {
|
||||
sendHookResponse('PreToolUse', result.trim());
|
||||
if (result) {
|
||||
sendHookResponse('PreToolUse', result);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
238
gitnexus/hooks/claude/hook-db-lock-probe.cjs
Normal file
238
gitnexus/hooks/claude/hook-db-lock-probe.cjs
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
/**
|
||||
* Cross-platform best-effort probe: does another process hold dbPath open
|
||||
* with a command line that looks like a GitNexus MCP/serve server?
|
||||
*
|
||||
* Backends (no user-installed Sysinternals):
|
||||
* - Linux: scan procfs under /proc (per-PID fd entries) via stat(2) (dev+inode); works without lsof;
|
||||
* optional lsof fallback when proc scan finds nothing.
|
||||
* - macOS / *BSD / etc.: trusted lsof + ps (absolute paths first).
|
||||
* - Windows: Restart Manager (rstrtmgr) via bundled PowerShell script +
|
||||
* Win32_Process for command lines; trusted powershell.exe under %SystemRoot%.
|
||||
*
|
||||
* Fail-open on most errors; fail-closed only on lsof ETIMEDOUT (Unix) or
|
||||
* PowerShell ETIMEDOUT (Windows), matching the hook contract.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
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 resolveHookBinary(tool) {
|
||||
const envKey = tool === 'lsof' ? 'GITNEXUS_HOOK_LSOF_PATH' : 'GITNEXUS_HOOK_PS_PATH';
|
||||
const fromEnv = process.env[envKey];
|
||||
if (fromEnv && String(fromEnv).trim() && fs.existsSync(String(fromEnv))) {
|
||||
return String(fromEnv);
|
||||
}
|
||||
const candidates =
|
||||
tool === 'lsof'
|
||||
? ['/usr/bin/lsof', '/usr/sbin/lsof', '/sbin/lsof', tool]
|
||||
: ['/bin/ps', '/usr/bin/ps', tool];
|
||||
for (const candidate of candidates) {
|
||||
if (candidate === tool) return tool;
|
||||
try {
|
||||
if (fs.existsSync(candidate)) return candidate;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
return tool;
|
||||
}
|
||||
|
||||
function resolveWindowsPowerShellPath() {
|
||||
const fromEnv = process.env.GITNEXUS_HOOK_POWERSHELL_PATH;
|
||||
if (fromEnv && String(fromEnv).trim() && fs.existsSync(String(fromEnv).trim())) {
|
||||
return String(fromEnv).trim();
|
||||
}
|
||||
const root = process.env.SystemRoot || 'C:\\Windows';
|
||||
const ps = path.join(root, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
|
||||
if (fs.existsSync(ps)) return ps;
|
||||
const psWow = path.join(root, 'SysWOW64', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
|
||||
if (fs.existsSync(psWow)) return psWow;
|
||||
return 'powershell.exe';
|
||||
}
|
||||
|
||||
// Sentinel:
|
||||
// undefined = not loaded yet (try the read)
|
||||
// string = encoded PowerShell command (successful load)
|
||||
// null = load attempted and failed (do not retry; warning already emitted)
|
||||
let windowsRmListPsEncodedCommandCache;
|
||||
let windowsRmListPsLoadFailureWarned = false;
|
||||
function getWindowsRmListEncodedCommand() {
|
||||
if (windowsRmListPsEncodedCommandCache !== undefined) {
|
||||
return windowsRmListPsEncodedCommandCache;
|
||||
}
|
||||
try {
|
||||
const ps1Path = path.join(__dirname, 'win-rm-list-json.ps1');
|
||||
const src = fs
|
||||
.readFileSync(ps1Path, 'utf8')
|
||||
.replace(/^\uFEFF/, '')
|
||||
.replace(/\r\n/g, '\n');
|
||||
windowsRmListPsEncodedCommandCache = Buffer.from(src, 'utf16le').toString('base64');
|
||||
} catch (err) {
|
||||
windowsRmListPsEncodedCommandCache = null;
|
||||
if (
|
||||
!windowsRmListPsLoadFailureWarned &&
|
||||
(process.env.GITNEXUS_DEBUG === '1' || process.env.GITNEXUS_DEBUG === 'true')
|
||||
) {
|
||||
windowsRmListPsLoadFailureWarned = true;
|
||||
const msg = err && err.message ? String(err.message).slice(0, 200) : 'unknown';
|
||||
process.stderr.write(`[GitNexus hook] win-rm-list-json.ps1 load failed: ${msg}\n`);
|
||||
}
|
||||
}
|
||||
return windowsRmListPsEncodedCommandCache;
|
||||
}
|
||||
|
||||
function hasGitNexusServerOwnerWindows(dbPathAbs, myPid) {
|
||||
const encoded = getWindowsRmListEncodedCommand();
|
||||
if (!encoded) return false;
|
||||
const psExe = resolveWindowsPowerShellPath();
|
||||
const r = spawnSync(
|
||||
psExe,
|
||||
[
|
||||
'-NoProfile',
|
||||
'-NonInteractive',
|
||||
'-ExecutionPolicy',
|
||||
'Bypass',
|
||||
'-STA',
|
||||
'-EncodedCommand',
|
||||
encoded,
|
||||
],
|
||||
{
|
||||
encoding: 'utf-8',
|
||||
timeout: 6000,
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
env: { ...process.env, GITNEXUS_HOOK_RM_TARGET: dbPathAbs },
|
||||
},
|
||||
);
|
||||
// ETIMEDOUT means the PowerShell probe didn't return in time; treat as 'unresponsive process holds DB' → fail-closed (skip augment).
|
||||
if (r.error) return r.error.code === 'ETIMEDOUT';
|
||||
if (r.status !== 0) return false;
|
||||
let rows;
|
||||
try {
|
||||
rows = JSON.parse(String(r.stdout || '').trim() || '[]');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (!Array.isArray(rows)) return false;
|
||||
for (const row of rows) {
|
||||
const procId = Number(row.pid);
|
||||
const cmd = String(row.cmd || '');
|
||||
if (!Number.isFinite(procId) || procId === myPid) continue;
|
||||
if (isGitNexusServerCommand(cmd)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function readLinuxCmdline(pidStr) {
|
||||
try {
|
||||
return fs.readFileSync(`/proc/${pidStr}/cmdline`, 'utf8').replace(/\0+/g, ' ').trim();
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function linuxProcScanFindGitNexusServer(dbPathAbs, myPid) {
|
||||
const raw = process.env.GITNEXUS_HOOK_LINUX_PROC_BUDGET_MS;
|
||||
const budget = Number(raw && String(raw).trim()) ? Number.parseInt(String(raw), 10) : 1200;
|
||||
const start = Date.now();
|
||||
let targetStat;
|
||||
try {
|
||||
targetStat = fs.statSync(dbPathAbs);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
let procEntries;
|
||||
try {
|
||||
procEntries = fs.readdirSync('/proc', { withFileTypes: true });
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
for (const ent of procEntries) {
|
||||
if (Date.now() - start > budget) return false;
|
||||
if (!ent.isDirectory() || !/^\d+$/.test(ent.name)) continue;
|
||||
const pid = Number.parseInt(ent.name, 10);
|
||||
if (!Number.isFinite(pid) || pid === myPid) continue;
|
||||
const fdDir = path.join('/proc', ent.name, 'fd');
|
||||
let fds;
|
||||
try {
|
||||
fds = fs.readdirSync(fdDir);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
let holds = false;
|
||||
for (const fd of fds) {
|
||||
if (Date.now() - start > budget) return false;
|
||||
try {
|
||||
const st = fs.statSync(path.join(fdDir, fd));
|
||||
if (st.dev === targetStat.dev && st.ino === targetStat.ino) {
|
||||
holds = true;
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
if (!holds) continue;
|
||||
if (isGitNexusServerCommand(readLinuxCmdline(ent.name))) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function unixLsofPsFindGitNexusServer(dbPathAbs, myPid) {
|
||||
const lsofPath = resolveHookBinary('lsof');
|
||||
const lsof = spawnSync(lsofPath, ['-nP', '-t', '--', dbPathAbs], {
|
||||
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);
|
||||
const psPath = resolveHookBinary('ps');
|
||||
for (const pid of pids) {
|
||||
if (Number(pid) === myPid) continue;
|
||||
const ps = spawnSync(psPath, ['-p', pid, '-o', 'command='], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 500,
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
});
|
||||
if (ps.error) {
|
||||
if (ps.error.code === 'ETIMEDOUT') return true;
|
||||
continue;
|
||||
}
|
||||
if (isGitNexusServerCommand(ps.stdout || '')) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} dbPath Absolute or relative path to the DB file (e.g. .../lbug).
|
||||
* @param {number} myPid Current process PID (hook runner), excluded from matches.
|
||||
*/
|
||||
function hasGitNexusDbLockedByGitNexusServer(dbPath, myPid) {
|
||||
if (!fs.existsSync(dbPath)) return false;
|
||||
const dbPathAbs = path.resolve(dbPath);
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
return hasGitNexusServerOwnerWindows(dbPathAbs, myPid);
|
||||
}
|
||||
|
||||
if (process.platform === 'linux') {
|
||||
if (linuxProcScanFindGitNexusServer(dbPathAbs, myPid)) return true;
|
||||
return unixLsofPsFindGitNexusServer(dbPathAbs, myPid);
|
||||
}
|
||||
|
||||
return unixLsofPsFindGitNexusServer(dbPathAbs, myPid);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
hasGitNexusDbLockedByGitNexusServer,
|
||||
};
|
||||
76
gitnexus/hooks/claude/win-rm-list-json.ps1
Normal file
76
gitnexus/hooks/claude/win-rm-list-json.ps1
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
$ErrorActionPreference = 'Stop'
|
||||
$target = $env:GITNEXUS_HOOK_RM_TARGET
|
||||
if ([string]::IsNullOrWhiteSpace($target)) { Write-Output '[]'; exit 0 }
|
||||
$target = (Resolve-Path -LiteralPath $target).ProviderPath
|
||||
|
||||
if (-not ([Management.Automation.PSTypeName]'GitNexusHookRm.Native').Type) {
|
||||
Add-Type @'
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
namespace GitNexusHookRm {
|
||||
public static class Native {
|
||||
public const int ErrorMoreData = 234;
|
||||
[StructLayout(LayoutKind.Sequential, Pack = 4)]
|
||||
public struct RM_UNIQUE_PROCESS {
|
||||
public int dwProcessId;
|
||||
public long ProcessStartTime;
|
||||
}
|
||||
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
||||
public struct RM_PROCESS_INFO {
|
||||
public RM_UNIQUE_PROCESS Process;
|
||||
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)]
|
||||
public string strAppName;
|
||||
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 64)]
|
||||
public string strServiceShortName;
|
||||
public uint ApplicationType;
|
||||
public uint AppStatus;
|
||||
public uint TSSessionId;
|
||||
public uint bRestartable;
|
||||
}
|
||||
[DllImport("rstrtmgr.dll", CharSet = CharSet.Unicode)]
|
||||
public static extern int RmStartSession(out uint pSessionHandle, uint dwSessionFlags, string strSessionKey);
|
||||
[DllImport("rstrtmgr.dll", CharSet = CharSet.Unicode)]
|
||||
public static extern int RmRegisterResources(uint pSessionHandle, uint nFiles, string[] rgsFileNames, uint nApplications, IntPtr rgApplications, uint nServices, string[] rgsServiceNames);
|
||||
[DllImport("rstrtmgr.dll")]
|
||||
public static extern int RmGetList(uint dwSessionHandle, out uint pnProcInfoNeeded, ref uint pnProcInfo, [In, Out] RM_PROCESS_INFO[] rgAffectedApps, ref uint lpdwRebootReasons);
|
||||
[DllImport("rstrtmgr.dll")]
|
||||
public static extern int RmEndSession(uint pSessionHandle);
|
||||
}
|
||||
}
|
||||
'@
|
||||
}
|
||||
|
||||
$h = [uint32]0
|
||||
$key = [guid]::NewGuid().ToString('N')
|
||||
$rmErr = [GitNexusHookRm.Native]::RmStartSession([ref]$h, 0, $key)
|
||||
if ($rmErr -ne 0) { Write-Output '[]'; exit 0 }
|
||||
$files = @($target)
|
||||
$err = [GitNexusHookRm.Native]::RmRegisterResources($h, 1, $files, 0, [IntPtr]::Zero, 0, $null)
|
||||
if ($err -ne 0) {
|
||||
[void][GitNexusHookRm.Native]::RmEndSession($h)
|
||||
Write-Output '[]'
|
||||
exit 0
|
||||
}
|
||||
$need = [uint32]0
|
||||
$n = [uint32]0
|
||||
$reboot = [uint32]0
|
||||
$err = [GitNexusHookRm.Native]::RmGetList($h, [ref]$need, [ref]$n, $null, [ref]$reboot)
|
||||
if ($err -ne [GitNexusHookRm.Native]::ErrorMoreData) {
|
||||
[void][GitNexusHookRm.Native]::RmEndSession($h)
|
||||
Write-Output '[]'
|
||||
exit 0
|
||||
}
|
||||
$n = $need
|
||||
$buf = New-Object GitNexusHookRm.Native+RM_PROCESS_INFO[] ([int]$n)
|
||||
$err = [GitNexusHookRm.Native]::RmGetList($h, [ref]$need, [ref]$n, $buf, [ref]$reboot)
|
||||
[void][GitNexusHookRm.Native]::RmEndSession($h)
|
||||
if ($err -ne 0) { Write-Output '[]'; exit 0 }
|
||||
|
||||
$out = @()
|
||||
for ($i = 0; $i -lt [int]$n; $i++) {
|
||||
$procId = $buf[$i].Process.dwProcessId
|
||||
$p = Get-CimInstance -ClassName Win32_Process -Filter "ProcessId=$procId" -ErrorAction SilentlyContinue
|
||||
$cmd = if ($p) { $p.CommandLine } else { '' }
|
||||
$out += [PSCustomObject]@{ pid = [int]$procId; cmd = $cmd }
|
||||
}
|
||||
ConvertTo-Json -InputObject @($out) -Compress
|
||||
|
|
@ -373,6 +373,24 @@ async function installClaudeCodeHooks(result: SetupResult): Promise<void> {
|
|||
// Helper not found in source — skip
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.copyFile(
|
||||
path.join(pluginHooksPath, 'hook-db-lock-probe.cjs'),
|
||||
path.join(destHooksDir, 'hook-db-lock-probe.cjs'),
|
||||
);
|
||||
} catch {
|
||||
// Helper not found in source — skip
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.copyFile(
|
||||
path.join(pluginHooksPath, 'win-rm-list-json.ps1'),
|
||||
path.join(destHooksDir, 'win-rm-list-json.ps1'),
|
||||
);
|
||||
} catch {
|
||||
// Helper not found in source — skip
|
||||
}
|
||||
|
||||
const hookPath = path.join(destHooksDir, 'gitnexus-hook.cjs').replace(/\\/g, '/');
|
||||
// Escape backslashes FIRST, then quotes (CodeQL js/incomplete-sanitization).
|
||||
// The previous shape `replace(/"/g, '\\"')` alone would let `path\with"quote`
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
* - shell injection: verifies no shell: true in spawnSync calls
|
||||
* - dispatch map: correct handler routing
|
||||
* - cross-platform: Windows .cmd extension handling
|
||||
* - cross-platform: DB lock probe (Linux /proc, Unix lsof, Windows RM)
|
||||
*
|
||||
* Since the hooks are CJS scripts that call main() on load, we test them
|
||||
* by spawning them as child processes with controlled stdin JSON.
|
||||
|
|
@ -45,6 +46,23 @@ const PLUGIN_HOOK_LOCK = path.resolve(
|
|||
'hooks',
|
||||
'hook-lock.js',
|
||||
);
|
||||
const CJS_HOOK_DB_PROBE = path.resolve(
|
||||
__dirname,
|
||||
'..',
|
||||
'..',
|
||||
'hooks',
|
||||
'claude',
|
||||
'hook-db-lock-probe.cjs',
|
||||
);
|
||||
const PLUGIN_HOOK_DB_PROBE = path.resolve(
|
||||
__dirname,
|
||||
'..',
|
||||
'..',
|
||||
'..',
|
||||
'gitnexus-claude-plugin',
|
||||
'hooks',
|
||||
'hook-db-lock-probe.cjs',
|
||||
);
|
||||
|
||||
// ─── Test fixtures: temporary .gitnexus directory ───────────────────
|
||||
|
||||
|
|
@ -109,6 +127,62 @@ function createGlobalRegistry(homeDir: string, marker: 'both' | 'registry' | 're
|
|||
}
|
||||
}
|
||||
|
||||
function writeExecutable(filePath: string, content: string) {
|
||||
fs.writeFileSync(filePath, content, { mode: 0o755 });
|
||||
}
|
||||
|
||||
function createHookToolDir(options: {
|
||||
gitnexusStderr?: string;
|
||||
gitnexusMarkerPath?: string;
|
||||
lsofOutput?: string;
|
||||
lsofOutputLines?: string[];
|
||||
psOutput?: string;
|
||||
psOutputByPid?: Record<string, string>;
|
||||
lsofSleepMs?: number;
|
||||
}) {
|
||||
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);
|
||||
|
||||
const lsofOutput =
|
||||
options.lsofOutputLines != null
|
||||
? options.lsofOutputLines.join('\n') + (options.lsofOutputLines.length ? '\n' : '')
|
||||
: (options.lsofOutput ?? '');
|
||||
const lsofBody =
|
||||
options.lsofSleepMs != null
|
||||
? `#!/usr/bin/env node\nsetTimeout(() => {}, ${Number(options.lsofSleepMs)});\n`
|
||||
: `#!/usr/bin/env node\nprocess.stdout.write(${JSON.stringify(lsofOutput)});\nprocess.exit(0);\n`;
|
||||
writeExecutable(path.join(binDir, 'lsof'), lsofBody);
|
||||
|
||||
const psBody =
|
||||
options.psOutputByPid != null
|
||||
? `#!/usr/bin/env node
|
||||
const byPid = ${JSON.stringify(options.psOutputByPid)};
|
||||
const args = process.argv;
|
||||
const p = args[args.indexOf('-p') + 1];
|
||||
process.stdout.write(byPid[p] ?? '');
|
||||
process.exit(0);
|
||||
`
|
||||
: `#!/usr/bin/env node\nprocess.stdout.write(${JSON.stringify(options.psOutput ?? '')});\nprocess.exit(0);\n`;
|
||||
writeExecutable(path.join(binDir, 'ps'), psBody);
|
||||
|
||||
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'),
|
||||
GITNEXUS_HOOK_LSOF_PATH: path.join(binDir, 'lsof'),
|
||||
GITNEXUS_HOOK_PS_PATH: path.join(binDir, 'ps'),
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Both hook files should exist ───────────────────────────────────
|
||||
|
||||
describe('Hook files exist', () => {
|
||||
|
|
@ -594,6 +668,430 @@ describe('PreToolUse concurrency guard (integration)', () => {
|
|||
}
|
||||
});
|
||||
|
||||
// ─── Source: cross-platform DB lock probe module (#1493) ─────────────
|
||||
|
||||
describe('Cross-platform DB lock probe (source)', () => {
|
||||
for (const [label, hookPath, probePath] of [
|
||||
['CJS', CJS_HOOK, CJS_HOOK_DB_PROBE],
|
||||
['Plugin', PLUGIN_HOOK, PLUGIN_HOOK_DB_PROBE],
|
||||
] as const) {
|
||||
it(`${label} probe file exists`, () => {
|
||||
expect(fs.existsSync(probePath)).toBe(true);
|
||||
});
|
||||
|
||||
it(`${label} hook requires hook-db-lock-probe.cjs`, () => {
|
||||
const source = fs.readFileSync(hookPath, 'utf-8');
|
||||
expect(source).toContain("require('./hook-db-lock-probe.cjs')");
|
||||
});
|
||||
|
||||
it(`${label} probe covers Linux /proc, Unix lsof, and Windows Restart Manager`, () => {
|
||||
const p = fs.readFileSync(probePath, 'utf-8');
|
||||
expect(p).toContain('win-rm-list-json.ps1');
|
||||
expect(p).toContain('/proc/');
|
||||
expect(p).toContain('linuxProcScanFindGitNexusServer');
|
||||
expect(p).toContain('unixLsofPsFindGitNexusServer');
|
||||
expect(p).toContain('hasGitNexusServerOwnerWindows');
|
||||
expect(p).toContain('GITNEXUS_HOOK_LSOF_PATH');
|
||||
expect(p).toContain('GITNEXUS_HOOK_POWERSHELL_PATH');
|
||||
expect(p).toContain('GITNEXUS_HOOK_LINUX_PROC_BUDGET_MS');
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Integration: PreToolUse augmentation filtering (#1492) ─────────
|
||||
|
||||
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 markerPath = path.join(os.tmpdir(), 'gn-hook-lockwarn-' + process.pid + '-' + label);
|
||||
fs.rmSync(markerPath, { force: true });
|
||||
const binDir = createHookToolDir({
|
||||
gitnexusMarkerPath: markerPath,
|
||||
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('');
|
||||
expect(fs.existsSync(markerPath)).toBe(true);
|
||||
|
||||
// Finding #18: when GITNEXUS_DEBUG=1 is set, the discarded prefix is
|
||||
// recoverable on the hook's stderr (not silently dropped).
|
||||
const debugResult = runHook(
|
||||
hookPath,
|
||||
{
|
||||
hook_event_name: 'PreToolUse',
|
||||
tool_name: 'Grep',
|
||||
tool_input: { pattern: 'validateUser' },
|
||||
cwd: tmpDir,
|
||||
},
|
||||
undefined,
|
||||
{ env: { ...hookEnv(binDir), GITNEXUS_DEBUG: '1' } },
|
||||
);
|
||||
expect(debugResult.stderr).toContain('augment stderr discarded prefix');
|
||||
expect(debugResult.stderr).toContain('Could not set lock on file');
|
||||
} finally {
|
||||
fs.rmSync(markerPath, { force: true });
|
||||
fs.rmSync(binDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
`${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(result.status).toBe(0);
|
||||
expect(result.stderr).toContain('[GitNexus] augment skipped');
|
||||
expect(fs.existsSync(markerPath)).toBe(false);
|
||||
} finally {
|
||||
fs.rmSync(markerPath, { force: true });
|
||||
fs.rmSync(binDir, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
describe.skipIf(process.platform === 'win32')(
|
||||
'Ladybug DB owner guard — production-shaped ps + failure modes (#1493)',
|
||||
() => {
|
||||
for (const [label, hookPath] of [
|
||||
['CJS', CJS_HOOK],
|
||||
['Plugin', PLUGIN_HOOK],
|
||||
] as const) {
|
||||
it(`${label}: skips augment for real node_modules/gitnexus ps line (npx child)`, () => {
|
||||
const markerPath = path.join(os.tmpdir(), `gn-hook-prodps-${process.pid}-${label}`);
|
||||
const lbugPath = path.join(gitNexusDir, 'lbug');
|
||||
fs.writeFileSync(lbugPath, '');
|
||||
fs.rmSync(markerPath, { force: true });
|
||||
const binDir = createHookToolDir({
|
||||
gitnexusMarkerPath: markerPath,
|
||||
lsofOutput: '99901\n',
|
||||
psOutput: 'node /tmp/node_modules/gitnexus/dist/cli/index.js 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(result.status).toBe(0);
|
||||
expect(result.stderr).toContain('[GitNexus] augment skipped');
|
||||
expect(fs.existsSync(markerPath)).toBe(false);
|
||||
} finally {
|
||||
fs.rmSync(markerPath, { force: true });
|
||||
fs.rmSync(binDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it(`${label}: npx parent command line is NOT treated as GitNexus server owner`, () => {
|
||||
const markerPath = path.join(os.tmpdir(), `gn-hook-npx-${process.pid}-${label}`);
|
||||
const lbugPath = path.join(gitNexusDir, 'lbug');
|
||||
fs.writeFileSync(lbugPath, '');
|
||||
fs.rmSync(markerPath, { force: true });
|
||||
const binDir = createHookToolDir({
|
||||
gitnexusMarkerPath: markerPath,
|
||||
gitnexusStderr: '[GitNexus] 1 related symbol found:\n\nvalidateUser (src/auth.ts)\n',
|
||||
lsofOutput: '99902\n',
|
||||
psOutput: 'npx -y gitnexus@latest mcp\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(fs.existsSync(markerPath)).toBe(true);
|
||||
} finally {
|
||||
fs.rmSync(markerPath, { force: true });
|
||||
fs.rmSync(binDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it(`${label}: skips augment for gitnexus serve child`, () => {
|
||||
const markerPath = path.join(os.tmpdir(), `gn-hook-serve-${process.pid}-${label}`);
|
||||
const lbugPath = path.join(gitNexusDir, 'lbug');
|
||||
fs.writeFileSync(lbugPath, '');
|
||||
fs.rmSync(markerPath, { force: true });
|
||||
const binDir = createHookToolDir({
|
||||
gitnexusMarkerPath: markerPath,
|
||||
lsofOutput: '99903\n',
|
||||
psOutput: 'node /repo/node_modules/gitnexus/dist/cli/index.js serve\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(result.status).toBe(0);
|
||||
expect(result.stderr).toContain('[GitNexus] augment skipped');
|
||||
expect(fs.existsSync(markerPath)).toBe(false);
|
||||
} finally {
|
||||
fs.rmSync(markerPath, { force: true });
|
||||
fs.rmSync(binDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it(`${label}: ENOENT lsof → augment still runs (fail-open)`, () => {
|
||||
const markerPath = path.join(os.tmpdir(), `gn-hook-enoent-${process.pid}-${label}`);
|
||||
const lbugPath = path.join(gitNexusDir, 'lbug');
|
||||
fs.writeFileSync(lbugPath, '');
|
||||
fs.rmSync(markerPath, { force: true });
|
||||
const binDir = createHookToolDir({
|
||||
gitnexusMarkerPath: markerPath,
|
||||
gitnexusStderr: '[GitNexus] 1 related symbol found:\n\nvalidateUser (src/auth.ts)\n',
|
||||
lsofOutput: '',
|
||||
psOutput: '',
|
||||
});
|
||||
try {
|
||||
const env = {
|
||||
...hookEnv(binDir),
|
||||
GITNEXUS_HOOK_LSOF_PATH: path.join(binDir, '__missing_lsof__'),
|
||||
};
|
||||
const result = runHook(
|
||||
hookPath,
|
||||
{
|
||||
hook_event_name: 'PreToolUse',
|
||||
tool_name: 'Grep',
|
||||
tool_input: { pattern: 'validateUser' },
|
||||
cwd: tmpDir,
|
||||
},
|
||||
undefined,
|
||||
{ env },
|
||||
);
|
||||
const output = parseHookOutput(result.stdout);
|
||||
expect(output).not.toBeNull();
|
||||
expect(fs.existsSync(markerPath)).toBe(true);
|
||||
} finally {
|
||||
fs.rmSync(markerPath, { force: true });
|
||||
fs.rmSync(binDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it(`${label}: ETIMEDOUT lsof → augment skipped (fail-closed)`, () => {
|
||||
const markerPath = path.join(os.tmpdir(), `gn-hook-etime-${process.pid}-${label}`);
|
||||
const lbugPath = path.join(gitNexusDir, 'lbug');
|
||||
fs.writeFileSync(lbugPath, '');
|
||||
fs.rmSync(markerPath, { force: true });
|
||||
const binDir = createHookToolDir({
|
||||
gitnexusMarkerPath: markerPath,
|
||||
lsofSleepMs: 5000,
|
||||
psOutput: '',
|
||||
});
|
||||
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(result.status).toBe(0);
|
||||
expect(result.stderr).toContain('[GitNexus] augment skipped');
|
||||
expect(fs.existsSync(markerPath)).toBe(false);
|
||||
} finally {
|
||||
fs.rmSync(markerPath, { force: true });
|
||||
fs.rmSync(binDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it(`${label}: non-GitNexus ps line → augment runs`, () => {
|
||||
const markerPath = path.join(os.tmpdir(), `gn-hook-other-${process.pid}-${label}`);
|
||||
const lbugPath = path.join(gitNexusDir, 'lbug');
|
||||
fs.writeFileSync(lbugPath, '');
|
||||
fs.rmSync(markerPath, { force: true });
|
||||
const binDir = createHookToolDir({
|
||||
gitnexusMarkerPath: markerPath,
|
||||
gitnexusStderr: '[GitNexus] 1 related symbol found:\n\nvalidateUser (src/auth.ts)\n',
|
||||
lsofOutput: '99904\n',
|
||||
psOutput: '/usr/bin/bash -l\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(fs.existsSync(markerPath)).toBe(true);
|
||||
} finally {
|
||||
fs.rmSync(markerPath, { force: true });
|
||||
fs.rmSync(binDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it(`${label}: multiple PIDs — skip if any ps line is GitNexus MCP`, () => {
|
||||
const markerPath = path.join(os.tmpdir(), `gn-hook-multi-${process.pid}-${label}`);
|
||||
const lbugPath = path.join(gitNexusDir, 'lbug');
|
||||
fs.writeFileSync(lbugPath, '');
|
||||
fs.rmSync(markerPath, { force: true });
|
||||
const binDir = createHookToolDir({
|
||||
gitnexusMarkerPath: markerPath,
|
||||
gitnexusStderr: '[GitNexus] 1 related symbol found:\n\nvalidateUser (src/auth.ts)\n',
|
||||
lsofOutputLines: ['111', '222'],
|
||||
psOutputByPid: {
|
||||
'111': 'vim /tmp/x\n',
|
||||
'222': 'node /x/node_modules/gitnexus/dist/cli/index.js 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(result.status).toBe(0);
|
||||
expect(result.stderr).toContain('[GitNexus] augment skipped');
|
||||
expect(fs.existsSync(markerPath)).toBe(false);
|
||||
} finally {
|
||||
fs.rmSync(markerPath, { force: true });
|
||||
fs.rmSync(binDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it(`${label}: ps ENOENT → augment runs (ignore that PID)`, () => {
|
||||
const markerPath = path.join(os.tmpdir(), `gn-hook-pseno-${process.pid}-${label}`);
|
||||
const lbugPath = path.join(gitNexusDir, 'lbug');
|
||||
fs.writeFileSync(lbugPath, '');
|
||||
fs.rmSync(markerPath, { force: true });
|
||||
const binDir = createHookToolDir({
|
||||
gitnexusMarkerPath: markerPath,
|
||||
gitnexusStderr: '[GitNexus] 1 related symbol found:\n\nvalidateUser (src/auth.ts)\n',
|
||||
lsofOutput: '99905\n',
|
||||
psOutput: '',
|
||||
});
|
||||
try {
|
||||
const env = {
|
||||
...hookEnv(binDir),
|
||||
GITNEXUS_HOOK_PS_PATH: path.join(binDir, '__missing_ps__'),
|
||||
};
|
||||
const result = runHook(
|
||||
hookPath,
|
||||
{
|
||||
hook_event_name: 'PreToolUse',
|
||||
tool_name: 'Grep',
|
||||
tool_input: { pattern: 'validateUser' },
|
||||
cwd: tmpDir,
|
||||
},
|
||||
undefined,
|
||||
{ env },
|
||||
);
|
||||
const output = parseHookOutput(result.stdout);
|
||||
expect(output).not.toBeNull();
|
||||
expect(fs.existsSync(markerPath)).toBe(true);
|
||||
} finally {
|
||||
fs.rmSync(markerPath, { force: true });
|
||||
fs.rmSync(binDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ─── Integration: PostToolUse staleness detection ───────────────────
|
||||
|
||||
describe('PostToolUse staleness detection (integration)', () => {
|
||||
|
|
|
|||
|
|
@ -267,6 +267,21 @@ describe('setupClaudeCode', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('copies hook-db-lock-probe.cjs and win-rm-list-json.ps1 to ~/.claude/hooks/gitnexus/', async () => {
|
||||
setPlatform('linux');
|
||||
|
||||
const { setupCommand } = await import('../../src/cli/setup.js');
|
||||
await setupCommand();
|
||||
|
||||
const destHooksDir = path.join(tempHome, '.claude', 'hooks', 'gitnexus');
|
||||
await expect(
|
||||
fs.access(path.join(destHooksDir, 'hook-db-lock-probe.cjs')),
|
||||
).resolves.toBeUndefined();
|
||||
await expect(
|
||||
fs.access(path.join(destHooksDir, 'win-rm-list-json.ps1')),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('falls back to first line on Windows when no .cmd/.bat wrapper found', async () => {
|
||||
setPlatform('win32');
|
||||
// Edge case: where returns only the POSIX script (no .cmd wrapper)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue