GitNexus/gitnexus/test/unit/git.test.ts
ManniX-ITA 39e9b40136
fix(windows): pass windowsHide:true to every child_process spawn-family call (#1794)
* 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>
2026-05-24 09:51:21 +01:00

227 lines
8 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { execSync } from 'child_process';
import fs from 'fs';
import os from 'os';
import path from 'path';
import {
isGitRepo,
getCurrentCommit,
getGitRoot,
findGitRootByDotGit,
parseRepoNameFromUrl,
sanitizeRepoName,
} from '../../src/storage/git.js';
// Mock child_process.execSync
vi.mock('child_process', () => ({
execSync: vi.fn(),
}));
const mockExecSync = vi.mocked(execSync);
describe('git utilities', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('isGitRepo', () => {
it('returns true when inside a git work tree', () => {
mockExecSync.mockReturnValueOnce(Buffer.from(''));
expect(isGitRepo('/project')).toBe(true);
expect(mockExecSync).toHaveBeenCalledWith('git rev-parse --is-inside-work-tree', {
cwd: '/project',
stdio: 'ignore',
windowsHide: true,
});
});
it('returns false when not a git repo', () => {
mockExecSync.mockImplementationOnce(() => {
throw new Error('not a git repo');
});
expect(isGitRepo('/not-a-repo')).toBe(false);
});
it('passes the correct cwd', () => {
mockExecSync.mockReturnValueOnce(Buffer.from(''));
isGitRepo('/some/path');
expect(mockExecSync).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ cwd: '/some/path' }),
);
});
});
describe('getCurrentCommit', () => {
it('returns trimmed commit hash', () => {
mockExecSync.mockReturnValueOnce(Buffer.from('abc123def\n'));
expect(getCurrentCommit('/project')).toBe('abc123def');
});
it('returns empty string on error', () => {
mockExecSync.mockImplementationOnce(() => {
throw new Error('not a git repo');
});
expect(getCurrentCommit('/not-a-repo')).toBe('');
});
it('trims whitespace from output', () => {
mockExecSync.mockReturnValueOnce(Buffer.from(' sha256hash \n'));
expect(getCurrentCommit('/project')).toBe('sha256hash');
});
});
describe('getGitRoot', () => {
it('returns resolved path on success', () => {
mockExecSync.mockReturnValueOnce(Buffer.from('/d/Projects/MyRepo\n'));
const result = getGitRoot('/d/Projects/MyRepo/src');
expect(result).toBeTruthy();
// path.resolve normalizes the git output
expect(typeof result).toBe('string');
});
it('returns null when not in a git repo', () => {
mockExecSync.mockImplementationOnce(() => {
throw new Error('not a git repo');
});
expect(getGitRoot('/not-a-repo')).toBeNull();
});
it('calls git rev-parse --show-toplevel', () => {
mockExecSync.mockReturnValueOnce(Buffer.from('/repo\n'));
getGitRoot('/repo/src');
expect(mockExecSync).toHaveBeenCalledWith(
'git rev-parse --show-toplevel',
expect.objectContaining({ cwd: '/repo/src' }),
);
});
it('trims output before resolving path', () => {
mockExecSync.mockReturnValueOnce(Buffer.from(' /repo \n'));
const result = getGitRoot('/repo/src');
expect(result).not.toBeNull();
expect(result!.trim()).toBe(result);
});
});
describe('findGitRootByDotGit', () => {
it('finds an ancestor .git directory without spawning git', () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-dotgit-'));
try {
fs.mkdirSync(path.join(tmpDir, '.git'));
const nested = path.join(tmpDir, 'packages', 'app');
fs.mkdirSync(nested, { recursive: true });
expect(findGitRootByDotGit(nested)).toBe(path.resolve(tmpDir));
expect(mockExecSync).not.toHaveBeenCalled();
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
it('returns null outside a git worktree without spawning git', () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-nonrepo-'));
try {
expect(findGitRootByDotGit(tmpDir)).toBeNull();
expect(mockExecSync).not.toHaveBeenCalled();
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
// Linked worktrees and submodules use a `.git` file (not directory) that
// points at the real gitdir. statSync succeeds for both, so the ancestor
// walk should treat such roots identically to ordinary repos.
it('treats a .git file (linked worktree) as a valid root', () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-worktree-'));
try {
fs.writeFileSync(path.join(tmpDir, '.git'), 'gitdir: /fake/worktrees/wt\n');
const nested = path.join(tmpDir, 'src', 'pkg');
fs.mkdirSync(nested, { recursive: true });
expect(findGitRootByDotGit(nested)).toBe(path.resolve(tmpDir));
expect(mockExecSync).not.toHaveBeenCalled();
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
it('returns null when the input path does not exist', () => {
const missing = path.join(os.tmpdir(), `gitnexus-missing-${Date.now()}-${Math.random()}`);
expect(findGitRootByDotGit(missing)).toBeNull();
expect(mockExecSync).not.toHaveBeenCalled();
});
it('walks from a file input by starting at its parent directory', () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-fileinput-'));
try {
fs.mkdirSync(path.join(tmpDir, '.git'));
const filePath = path.join(tmpDir, 'pkg', 'index.ts');
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, 'export {};\n');
expect(findGitRootByDotGit(filePath)).toBe(path.resolve(tmpDir));
expect(mockExecSync).not.toHaveBeenCalled();
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
});
describe('sanitizeRepoName', () => {
it('strips leading dashes', () => {
expect(sanitizeRepoName('--repo')).toBe('repo');
});
it('replaces unsafe characters with underscores', () => {
expect(sanitizeRepoName('repo<tag>')).toBe('repo_tag_');
expect(sanitizeRepoName('repo:name')).toBe('repo_name');
expect(sanitizeRepoName('repo"quoted"')).toBe('repo_quoted_');
});
it('blocks path traversal segments', () => {
expect(sanitizeRepoName('.')).toBe('unknown');
expect(sanitizeRepoName('..')).toBe('unknown');
});
it('blocks Windows reserved names', () => {
expect(sanitizeRepoName('CON')).toBe('unknown');
expect(sanitizeRepoName('prn')).toBe('unknown');
expect(sanitizeRepoName('AUX')).toBe('unknown');
expect(sanitizeRepoName('NUL')).toBe('unknown');
expect(sanitizeRepoName('COM1')).toBe('unknown');
expect(sanitizeRepoName('LPT9')).toBe('unknown');
// Reserved names with extensions
expect(sanitizeRepoName('CON.txt')).toBe('unknown');
expect(sanitizeRepoName('NUL.tar.gz')).toBe('unknown');
expect(sanitizeRepoName('AUX.local')).toBe('unknown');
});
it('returns unknown for empty or invalid input', () => {
expect(sanitizeRepoName('')).toBe('unknown');
expect(sanitizeRepoName('---')).toBe('unknown');
});
});
describe('parseRepoNameFromUrl', () => {
it('extracts and sanitizes name from HTTPS URL', () => {
expect(parseRepoNameFromUrl('https://github.com/user/my-repo.git')).toBe('my-repo');
expect(parseRepoNameFromUrl('https://github.com/user/--payload.git')).toBe('payload');
});
it('extracts and sanitizes name from SSH URL', () => {
expect(parseRepoNameFromUrl('git@github.com:user/my-repo.git')).toBe('my-repo');
expect(parseRepoNameFromUrl('git@github.com:--payload.git')).toBe('payload');
});
it('returns null for all-dash inputs (prevents registry collision)', () => {
expect(parseRepoNameFromUrl('https://github.com/user/---.git')).toBeNull();
});
it('returns null for empty URL', () => {
expect(parseRepoNameFromUrl('')).toBeNull();
expect(parseRepoNameFromUrl(null)).toBeNull();
});
});
});