mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-05 02:43:32 +00:00
* fix(hooks): pass windowsHide:true to every spawnSync to suppress flashing console windows on Windows
On Windows, every PostToolUse and Stop event from Claude Code (and
the Cursor integration variant) cold-spawns ``node`` / ``npx.cmd`` /
``git`` / ``lsof`` through ``child_process.spawnSync``. Without
``windowsHide: true`` in the options, Node's child_process module
asks ``CreateProcess`` to use ``STARTF_USESHOWWINDOW`` with
``SW_SHOWDEFAULT``, and a black console window flashes onto the
user's desktop for the duration of the call. Under active
editor / agent use this means a near-continuous stream of pop-up
windows — unusable in practice (reported live on a Windows 11
workstation running the gitnexus Claude plugin against an active
project; the flashes stack on the taskbar and steal focus from the
editor).
The Node fix is one option flag per spawnSync:
spawnSync(cmd, args, {
encoding: 'utf-8',
timeout,
cwd,
stdio: ['pipe', 'pipe', 'pipe'],
windowsHide: true, // <-- new
});
``windowsHide`` is a no-op on macOS/Linux (Node docs: "Hide the
subprocess console window that would normally be created on Windows
systems"), so the patch is platform-neutral and zero-risk on the
other two majors.
This commit touches every ``spawnSync`` call in the three sources
that ship the hook layer:
* gitnexus/hooks/claude/gitnexus-hook.cjs (4 sites)
* gitnexus/hooks/claude/hook-db-lock-probe.cjs (3 sites)
* gitnexus-claude-plugin/hooks/gitnexus-hook.js (6 sites)
* gitnexus-claude-plugin/hooks/hook-db-lock-probe.cjs (3 sites)
* gitnexus-cursor-integration/hooks/gitnexus-hook.cjs (3 sites)
Total: 19 spawn sites guarded. ``hook-lock.cjs`` / ``hook-lock.js``
don't spawn subprocesses; nothing else in the hooks/ dirs touches
``child_process``.
Verified on Windows 10 22H2 / Node 22.21 / gitnexus 1.6.5 by
installing the locally-built tarball and running an active Claude
Code session against a large mixed-language repo — no console
window appears for any hook fire (pre-fix: ~2-3 visible flashes per
edit). No behavioural change on Linux/macOS hosts.
* test(hooks): regression — every hook spawnSync paired with windowsHide:true
Source-level assertion that every ``spawnSync`` invocation in the
hook layer has a matching ``windowsHide: true`` in its options
object. Without the flag, Node's child_process module asks
CreateProcess to use STARTF_USESHOWWINDOW with SW_SHOWDEFAULT and
a black console window flashes onto the user's desktop for the
duration of each call — see the parent fix commit.
The check is source-level rather than behavioural because:
* the flag's effect is observable only on Windows;
* GitHub Actions runs vitest on Linux for the hook tests;
* regressing this is easy (every new spawnSync site has to remember
to add the flag), and a runtime check on a Windows-only CI leg
would still let a PR land on the main branch first.
Counts spawnSync occurrences and windowsHide:true occurrences per
file (in code, ignoring comments) and asserts equality. Five files
covered:
* gitnexus/hooks/claude/gitnexus-hook.cjs
* gitnexus/hooks/claude/hook-db-lock-probe.cjs
* gitnexus-claude-plugin/hooks/gitnexus-hook.js
* gitnexus-claude-plugin/hooks/hook-db-lock-probe.cjs
* gitnexus-cursor-integration/hooks/gitnexus-hook.cjs
Adding a new hook file requires updating the HOOK_FILES tuple. A
sanity assertion ``spawnCount > 0`` catches accidental deletion of
all spawn calls in a future refactor (would otherwise silently make
the count-equality assertion trivially true).
Sits next to the existing "no shell: true" and ".cmd extension"
regression tests in test/unit/hooks.test.ts — same shape, same
spirit.
* fix(src): extend windowsHide:true to every spawn-family call in cli/core/mcp/server
Companion to the hook-layer fix in this branch's first commit. The
same Windows console-window flash bug applies to every
``spawn`` / ``spawnSync`` / ``execFile`` / ``execFileSync`` /
``execFileAsync`` / ``execSync`` call in the source tree — not just
the hooks. The MCP local backend
(``src/mcp/local/local-backend.ts``) and the ``gitnexus serve`` git
helpers (``src/server/git-clone.ts``) are particularly bad because
they run from daemonized processes that have no parent console; the
spawned child auto-allocates one and it pops onto the user's
desktop. The CLI sites are less visible (the user is at a terminal
with an existing console; ``stdio: 'inherit'`` shares it) but the
flag is harmless there — windowsHide only suppresses NEW console
allocation, an inherited parent console is untouched. The visible
output of ``gitnexus analyze`` and friends is preserved verbatim.
The pre-existing fix at ``src/core/lbug/extension-loader.ts:96``
established the convention in this codebase. This commit applies it
uniformly.
Sites covered (21 new):
| File | Sites |
|---|---|
| src/cli/analyze.ts | 1 |
| src/cli/setup.ts | 2 |
| src/cli/wiki.ts | 3 |
| src/core/embeddings/embedder.ts | 1 |
| src/core/git-staleness.ts | 3 |
| src/core/run-analyze.ts | 1 |
| src/core/wiki/cursor-client.ts | 2 |
| src/core/wiki/generator.ts | 3 |
| src/mcp/local/local-backend.ts | 2 |
| src/server/git-clone.ts | 2 |
| src/core/lbug/extension-loader.ts | (already had it, untouched) |
Combined with the 19 hook sites from the first commit + the 1
pre-existing extension-loader site, the codebase now has uniform
``windowsHide: true`` on every spawn-family call.
Behavioural notes:
* ``windowsHide`` is documented by Node as a no-op on POSIX —
Linux/macOS hosts see byte-identical behaviour.
* ``stdio: 'inherit'`` callers (e.g. ``cli/wiki.ts:522`` opens the
editor in the user's terminal) keep their interactive UX. The
child inherits the parent's stdio handles; no new console is
allocated; the flag has nothing to hide.
* Piped callers (``stdio: ['pipe',…]``) continue to deliver every
byte of stdout/stderr back to the parent for the parent to log
/ process / re-print. No output is swallowed.
* ``execSync`` / ``execFileSync`` callers that previously had no
``stdio`` option (e.g. ``generator.ts:887`` ``execSync('git
rev-parse HEAD', { cwd })``) keep their default pipe semantics
(``.toString()`` still works) — windowsHide is added alongside
the existing ``cwd`` option.
Verified on Windows 10 22H2 / Node 22.21 by installing the locally
built tarball and exercising:
* MCP detect_changes via the local backend → no flash.
* gitnexus serve → no flash on git clone/clone-pull.
* gitnexus analyze interactively → output appears in terminal as
before, no extra window.
* test(windowsHide): extend regression to every spawn-family call in src/
Companion to the src/ patch. The hooks.test.ts regression now
covers 16 files (5 hooks + 11 source files), and asserts the
invariant for every spawn-family function — not just spawnSync.
Changes:
* Generalise countSpawnCalls() to also count spawn, execFile,
execFileSync, execFileAsync, execSync (the entire spawn-family
surface of child_process). Skip method calls (e.g. RegExp.exec)
via a negative-lookbehind on ``.``.
* Add SRC_FILES table with all 11 source-tree files that import
spawn-family functions from child_process.
* Loop over [...HOOK_FILES, ...SRC_FILES] so a regression in any
file fails the same test name.
* Tighten the assertion to ``hideCount >= spawnCount`` rather
than strict equality, because some sites (e.g. setup.ts:534
using execFileAsync via shell:true on Windows) may legitimately
add windowsHide to nested option objects in future refactors.
* Sanity gate ``spawnCount > 0`` per file catches a refactor
that deletes all spawn calls (would otherwise make the
assertion trivially true).
Manually exercised against the patched repo:
16 files, 28 total spawn-family calls, 28 windowsHide:true.
All pass.
The convention to keep this list in sync: every new file in
gitnexus/src/ that imports from 'child_process' must be added to
the SRC_FILES tuple. The cost is one line per file; the benefit
is the next contributor never has to think about windowsHide
again — the test will catch a miss before merge.
* style: prettier --write on storage/git.ts + hooks.test.ts
CI quality / format job flagged two formatting issues in the
merge-resolution commit: a long single-line options object in
storage/git.ts and similar in hooks.test.ts. prettier --write
fixes both with the project's standard wrap-and-trailing-comma
style. No semantic change.
* test(git): include windowsHide in toHaveBeenCalledWith assertion
The merge-resolution commit added windowsHide:true to the
'git rev-parse --is-inside-work-tree' execSync call in
src/storage/git.ts, but the matching strict-shape assertion in
git.test.ts:31-34 still expected the pre-patch two-key options
object {cwd, stdio}. vitest's toHaveBeenCalledWith does a deep
structural match, so the extra third key flipped the assertion
to fail.
Add windowsHide: true to the expected shape. Only this one
assertion is strict; the two siblings ('passes the correct cwd'
and the no-cwd-arg case) use expect.objectContaining and
expect.any(String) and remain green without modification.
* test(setup-codex): include windowsHide in execFile shape assertions
Same root cause as the git.test.ts fix on this branch: the windowsHide
patch added windowsHide:true to the execFile() options in
src/cli/setup.ts, but three strict-shape toHaveBeenCalledWith
assertions in setup-codex.test.ts still expected the pre-patch
{shell:true} / {shell:false} two-key options. vitest does a deep
structural match, so the extra key flipped the assertions to fail
on every CI matrix leg (ubuntu coverage + macos + windows).
Adding windowsHide:true alongside the existing 'shell' key in
all three sites.
* ci: retrigger checks
go-parity failed on a flaky onnxruntime-node postinstall network timeout
(AggregateError [ETIMEDOUT] in node ./script/install), which cascaded into
the CI Gate. No code change — empty commit to re-run the pipeline.
* fix(test): strengthen windowsHide regression assertions (PR #1794 review)
- Replace toBeGreaterThanOrEqual with exact toBe per DoD §2.7
- Remove unused `m` variable in countSpawnCalls (CodeQL finding)
- Add windowsHide: true to runGit test helper for consistency
---------
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: ManniX-ITA <35522085+ManniX-ITA@users.noreply.github.com>
Co-authored-by: Test <test@example.com>
241 lines
7.7 KiB
JavaScript
241 lines
7.7 KiB
JavaScript
/**
|
|
* 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'],
|
|
windowsHide: true,
|
|
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'],
|
|
windowsHide: true,
|
|
});
|
|
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'],
|
|
windowsHide: true,
|
|
});
|
|
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,
|
|
};
|