mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
* feat(setup): implement antigravity integration setup and hook adapter for gitnexus * docs(readme): list Antigravity in supported editors * test(setup-antigravity): pin platform per-test to fix Windows CI failure The MCP entry assertion expected `npx` directly, but on Windows `getMcpEntry()` wraps it as `cmd /c npx ...`, which broke the Windows runner. Pin platform to darwin in beforeEach so the existing assertion is deterministic, restore the descriptor in afterEach, and add a parity test for the win32 cmd-wrapper shape. * fix(antigravity): align hook adapter to Gemini CLI schema + fix Windows CI Rebase the Antigravity integration on the canonical Gemini CLI hooks contract (https://geminicli.com/docs/hooks/reference/), which is the documented schema Antigravity 2.0 inherits: - Hook adapter: replace PreToolUse/PostToolUse with the single AfterTool event. BeforeTool has no documented context-injection channel in the Gemini contract, so augmentation runs in AfterTool where hookSpecificOutput.additionalContext is the documented way to append text to the tool result the agent reads. Stale-index hints land in the same channel (so the agent sees them) and are mirrored to stderr for terminal users. Tool-name matcher updated to Gemini CLI snake_case (search_file_content|glob|run_shell_command). - Setup: write hooks to ~/.gemini/settings.json under canonical hooks.AfterTool[] (replaces the ad-hoc hooks.json top-level group). Polite-neighbor merge preserves existing user hooks. Also copy win-rm-list-json.ps1 alongside hook-db-lock-probe.cjs so the Windows MCP server ownership probe doesn't silently fail open. - Tests: 17 regression tests covering MCP write, win32 shape, hook schema, polite-neighbor merge, idempotency, adapter context emission, stale-index hint, and skill layout. - README: footnote documenting the AfterTool design choice and a link to the Gemini CLI hooks reference. Windows CI fix: installSkillsTo previously used glob('*.md') + glob('*/SKILL.md'), which returned zero matches under the Windows runner's temp paths (8.3 short-name like RUNNER~1). Replace with fs.readdir + dirent type checks — same behavior, no path quirks. This fixes the only failing Windows job on the PR. * fix(antigravity): address PR review — windowsHide, stale docs, dead code Addresses the production-readiness review findings on PR #1730: - F1 (blocker): add windowsHide:true to all four spawnSync sites in the Antigravity hook adapter (findCanonicalRepoRoot, runGitNexusCli's two branches, buildStaleIndexHint) so they don't flash console windows on Windows. Matches the fix #1794 already on main for the Claude hook. - F2 (blocker): update gitnexus/README.md editor table to say AfterTool and link the Gemini CLI hooks reference. The published README had drifted to the pre-c1872b4 PreToolUse + PostToolUse schema. - F3: rewrite the stale ~/.gemini block comment in setup.ts. It still described the old hooks.json + gitnexus group + grep_search design. - F4: remove grep_search dead code from extractPattern and its doc comment. The registered matcher is search_file_content|glob|run_shell_command, so grep_search would never be invoked. - F5: annotate timeout:10000 with a ms-unit comment noting Gemini CLI uses milliseconds (Claude Code uses seconds). - F6: add the GITNEXUS_DEBUG branch to extractAugmentContext for parity with the Claude adapter, so suppressed augment stderr is recoverable. - F7: stageAdapter test helper now copies win-rm-list-json.ps1 alongside the .cjs helpers, so the adapter's Windows lock-probe path isn't a silent fail-open in child-process smoke tests. * test(antigravity): add integration tests and register in cross-platform matrix Adds end-to-end coverage on top of the unit-level tests, per maintainer request: - test/integration/setup-antigravity.test.ts (10 tests): exercises the real setupCommand() against a temp HOME with ~/.gemini/antigravity/ present. Verifies mcp_config.json shape, ~/.gemini/settings.json AfterTool entry, adapter + helpers + win-rm-list-json.ps1 copy, baked-in cliPath rewrite (issue #108 regression class), skill layout, polite-neighbor merge against existing user hooks, idempotency, skip-when-absent, corrupt-file safety, and key preservation. - test/integration/antigravity-hook-e2e.test.ts (19 tests): runs the full install-then-execute flow — invokes setupCommand to lay down the adapter + helpers, then spawns the INSTALLED adapter as a real child process against a temp git repo + .gitnexus/. The source adapter cannot be spawned directly (it requires sibling .cjs helpers that only live in hooks/claude/); install-then-spawn mirrors the production codepath. Covers staleness detection across all five git mutation types, --embeddings propagation, polite skip on toolResponse.error / exit_code !== 0, augment crash-free behavior, cwd validation, corrupted/missing meta.json, unknown event names, empty stdin, and the no-.gitnexus deep-nested case. - scripts/cross-platform-tests.ts: registers all three antigravity test files (unit in PLATFORM_LOGIC, two integration files in SPAWN_CLI) so Windows and macOS CI exercise them on every run. * fix(antigravity): review fixes — dedup, silent-failure guard, type coercion, glob filter - Delete mergeGeminiSettingsHooks (verbatim copy of mergeHooksJsonc), replace call site with the original - Unify geminiHasGitnexusHook into hasGitnexusHook with commandFragment parameter; delete the duplicate - Guard against silent adapter-copy failure: verify the adapter file exists before registering the AfterTool hook entry in settings.json; surface helper copy errors instead of swallowing - Fix toolSucceeded type coercion: use Number() so string exit_code values from Gemini CLI are handled correctly - Align glob tool extractPattern with Claude adapter's restrictive regex filter (/[*\/]([a-zA-Z][a-zA-Z0-9_-]{2,})/) - Remove bounds-only toBeGreaterThan(0) assertion (DoD §2.7) - Add antigravity adapter to HOOK_FILES windowsHide regression list * chore(autofix): apply prettier + eslint fixes via /autofix command * chore: trigger CI --------- Co-authored-by: Gergő Magyar <gergomagyar@icloud.com> Co-authored-by: Test <test@example.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
262 lines
9.7 KiB
TypeScript
262 lines
9.7 KiB
TypeScript
/**
|
|
* Integration Tests: setupCommand — Antigravity end-to-end
|
|
*
|
|
* Exercises the real `setupCommand()` (no mocks) against a temp HOME with
|
|
* `~/.gemini/antigravity/` present and verifies the on-disk artifacts: MCP
|
|
* config, ~/.gemini/settings.json hooks entry, hook adapter + helpers
|
|
* (including win-rm-list-json.ps1), and installed skills.
|
|
*
|
|
* Complements the unit-level setup-antigravity test by running the actual
|
|
* setup pipeline end-to-end with real filesystem state rather than mocked
|
|
* spawn/spawnSync.
|
|
*/
|
|
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
|
|
import fs from 'fs/promises';
|
|
import path from 'path';
|
|
import os from 'os';
|
|
import { fileURLToPath } from 'url';
|
|
import { setupCommand } from '../../src/cli/setup.js';
|
|
|
|
const testDir = path.dirname(fileURLToPath(import.meta.url));
|
|
const packageRoot = path.resolve(testDir, '..', '..');
|
|
const adapterSource = path.join(
|
|
packageRoot,
|
|
'hooks',
|
|
'antigravity',
|
|
'gitnexus-antigravity-hook.cjs',
|
|
);
|
|
|
|
describe('setupCommand Antigravity integration', () => {
|
|
let tempHome: string;
|
|
const originalHome = process.env.HOME;
|
|
const originalUserProfile = process.env.USERPROFILE;
|
|
let antigravityDir: string;
|
|
let geminiDir: string;
|
|
|
|
beforeAll(async () => {
|
|
tempHome = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-antigravity-int-'));
|
|
process.env.HOME = tempHome;
|
|
// os.homedir() honors USERPROFILE on Windows
|
|
process.env.USERPROFILE = tempHome;
|
|
geminiDir = path.join(tempHome, '.gemini');
|
|
antigravityDir = path.join(geminiDir, 'antigravity');
|
|
});
|
|
|
|
afterAll(async () => {
|
|
process.env.HOME = originalHome;
|
|
process.env.USERPROFILE = originalUserProfile;
|
|
await fs.rm(tempHome, { recursive: true, force: true });
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
// Reset ~/.gemini between tests so each starts from a clean slate but
|
|
// keeps the antigravity/ marker dir present (so setupAntigravity runs).
|
|
// Tests that need to verify the "not installed" skip path remove the
|
|
// marker themselves and restore it at the end.
|
|
await fs.rm(geminiDir, { recursive: true, force: true });
|
|
await fs.mkdir(antigravityDir, { recursive: true });
|
|
});
|
|
|
|
it('writes mcp_config.json with a valid mcpServers.gitnexus entry', async () => {
|
|
await setupCommand();
|
|
|
|
const raw = await fs.readFile(path.join(antigravityDir, 'mcp_config.json'), 'utf-8');
|
|
const config = JSON.parse(raw);
|
|
|
|
expect(config.mcpServers).toBeDefined();
|
|
expect(config.mcpServers.gitnexus).toBeDefined();
|
|
expect(typeof config.mcpServers.gitnexus.command).toBe('string');
|
|
expect(Array.isArray(config.mcpServers.gitnexus.args)).toBe(true);
|
|
// mcp is always the final positional regardless of which command shape
|
|
// (global binary, npx, or cmd /c npx wrapper) is chosen
|
|
expect(config.mcpServers.gitnexus.args).toContain('mcp');
|
|
});
|
|
|
|
it('registers an AfterTool entry in ~/.gemini/settings.json with the canonical matcher', async () => {
|
|
await setupCommand();
|
|
|
|
const settingsPath = path.join(geminiDir, 'settings.json');
|
|
const config = JSON.parse(await fs.readFile(settingsPath, 'utf-8'));
|
|
|
|
expect(config.hooks).toBeDefined();
|
|
expect(config.hooks.AfterTool).toBeInstanceOf(Array);
|
|
expect(config.hooks.AfterTool).toHaveLength(1);
|
|
|
|
const entry = config.hooks.AfterTool[0];
|
|
expect(entry.matcher).toBe('search_file_content|glob|run_shell_command');
|
|
expect(Array.isArray(entry.hooks)).toBe(true);
|
|
expect(entry.hooks).toHaveLength(1);
|
|
|
|
const hook = entry.hooks[0];
|
|
expect(hook.type).toBe('command');
|
|
expect(hook.name).toBe('gitnexus');
|
|
expect(hook.command).toMatch(/gitnexus-antigravity-hook\.cjs/);
|
|
// ms — Gemini CLI uses milliseconds; 10000 ms = 10 s
|
|
expect(hook.timeout).toBe(10000);
|
|
});
|
|
|
|
it('copies the adapter and all required helpers (including win-rm-list-json.ps1) to ~/.gemini/config/hooks/gitnexus/', async () => {
|
|
await setupCommand();
|
|
|
|
const hooksDir = path.join(geminiDir, 'config', 'hooks', 'gitnexus');
|
|
for (const file of [
|
|
'gitnexus-antigravity-hook.cjs',
|
|
'hook-lock.cjs',
|
|
'hook-db-lock-probe.cjs',
|
|
// Required by hook-db-lock-probe.cjs on Windows; without it the MCP
|
|
// server ownership probe silently fails open and the adapter can race
|
|
// the MCP server on the LadybugDB.
|
|
'win-rm-list-json.ps1',
|
|
]) {
|
|
await expect(
|
|
fs.access(path.join(hooksDir, file)),
|
|
`expected ${file} to be installed`,
|
|
).resolves.toBeUndefined();
|
|
}
|
|
});
|
|
|
|
it('rewrites the adapter cliPath to an absolute resolved path at install time', async () => {
|
|
await setupCommand();
|
|
|
|
const installed = await fs.readFile(
|
|
path.join(geminiDir, 'config', 'hooks', 'gitnexus', 'gitnexus-antigravity-hook.cjs'),
|
|
'utf-8',
|
|
);
|
|
const source = await fs.readFile(adapterSource, 'utf-8');
|
|
|
|
// The source default uses path.resolve(__dirname, '..', '..', 'dist', ...)
|
|
// which would resolve incorrectly when the adapter is installed outside
|
|
// the gitnexus package tree (issue #108 regression class). Setup must
|
|
// replace it with a JSON-string absolute literal pointing at the real CLI.
|
|
// Under vitest/tsx the resolved __dirname of setup.ts is src/cli/, so the
|
|
// rewrite resolves to src/cli/index.js; under a packaged install it
|
|
// resolves to dist/cli/index.js. Accept either.
|
|
expect(source).toMatch(
|
|
/path\.resolve\(__dirname, '\.\.', '\.\.', 'dist', 'cli', 'index\.js'\)/,
|
|
);
|
|
expect(installed).not.toMatch(
|
|
/let cliPath = path\.resolve\(__dirname, '\.\.', '\.\.', 'dist', 'cli', 'index\.js'\)/,
|
|
);
|
|
expect(installed).toMatch(/let cliPath = "[^"]*(?:src|dist)\/cli\/index\.js"/);
|
|
});
|
|
|
|
it('installs gitnexus skills into ~/.gemini/antigravity/skills/<name>/SKILL.md', async () => {
|
|
await setupCommand();
|
|
|
|
const skillsDir = path.join(antigravityDir, 'skills');
|
|
const entries = await fs.readdir(skillsDir, { withFileTypes: true });
|
|
const skillNames = entries.filter((e) => e.isDirectory()).map((e) => e.name);
|
|
|
|
expect(skillNames).toContain('gitnexus-cli');
|
|
|
|
const cliSkill = await fs.readFile(path.join(skillsDir, 'gitnexus-cli', 'SKILL.md'), 'utf-8');
|
|
expect(cliSkill).toMatch(/GitNexus/i);
|
|
});
|
|
|
|
it('preserves user hooks under BeforeTool and other AfterTool matchers (polite-neighbor merge)', async () => {
|
|
const settingsPath = path.join(geminiDir, 'settings.json');
|
|
await fs.writeFile(
|
|
settingsPath,
|
|
JSON.stringify(
|
|
{
|
|
theme: 'dark',
|
|
hooks: {
|
|
BeforeTool: [
|
|
{
|
|
matcher: 'write_file',
|
|
hooks: [{ type: 'command', command: 'echo before', name: 'user-fmt' }],
|
|
},
|
|
],
|
|
AfterTool: [
|
|
{
|
|
matcher: 'write_file',
|
|
hooks: [{ type: 'command', command: 'echo after', name: 'user-fmt' }],
|
|
},
|
|
],
|
|
},
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
'utf-8',
|
|
);
|
|
|
|
await setupCommand();
|
|
|
|
const config = JSON.parse(await fs.readFile(settingsPath, 'utf-8'));
|
|
|
|
// Unrelated keys preserved
|
|
expect(config.theme).toBe('dark');
|
|
|
|
// User's BeforeTool entry untouched
|
|
expect(config.hooks.BeforeTool).toHaveLength(1);
|
|
expect(config.hooks.BeforeTool[0].hooks[0].command).toBe('echo before');
|
|
|
|
// Our AfterTool entry appended after the user's
|
|
expect(config.hooks.AfterTool).toHaveLength(2);
|
|
expect(config.hooks.AfterTool[0].hooks[0].command).toBe('echo after');
|
|
expect(config.hooks.AfterTool[1].hooks[0].command).toMatch(/gitnexus-antigravity-hook/);
|
|
});
|
|
|
|
it('is idempotent — re-running setupCommand does not duplicate the AfterTool entry', async () => {
|
|
await setupCommand();
|
|
await setupCommand();
|
|
await setupCommand();
|
|
|
|
const config = JSON.parse(await fs.readFile(path.join(geminiDir, 'settings.json'), 'utf-8'));
|
|
expect(config.hooks.AfterTool).toHaveLength(1);
|
|
|
|
const mcpConfig = JSON.parse(
|
|
await fs.readFile(path.join(antigravityDir, 'mcp_config.json'), 'utf-8'),
|
|
);
|
|
// Re-running setup should also leave mcpServers.gitnexus as the single
|
|
// canonical entry, not duplicate it.
|
|
expect(Object.keys(mcpConfig.mcpServers)).toEqual(['gitnexus']);
|
|
});
|
|
|
|
it('skips Antigravity setup entirely when ~/.gemini/antigravity is absent', async () => {
|
|
await fs.rm(geminiDir, { recursive: true, force: true });
|
|
|
|
await setupCommand();
|
|
|
|
// Neither the MCP config nor the hooks settings should be created when
|
|
// Antigravity is not installed.
|
|
await expect(fs.access(path.join(geminiDir, 'settings.json'))).rejects.toThrow();
|
|
await expect(fs.access(path.join(antigravityDir, 'mcp_config.json'))).rejects.toThrow();
|
|
});
|
|
|
|
it('preserves existing keys and other servers when merging into mcp_config.json', async () => {
|
|
await fs.writeFile(
|
|
path.join(antigravityDir, 'mcp_config.json'),
|
|
JSON.stringify(
|
|
{
|
|
existingKey: 'keep-me',
|
|
mcpServers: { other: { command: 'foo', args: ['bar'] } },
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
'utf-8',
|
|
);
|
|
|
|
await setupCommand();
|
|
|
|
const config = JSON.parse(
|
|
await fs.readFile(path.join(antigravityDir, 'mcp_config.json'), 'utf-8'),
|
|
);
|
|
expect(config.existingKey).toBe('keep-me');
|
|
expect(config.mcpServers.other).toEqual({ command: 'foo', args: ['bar'] });
|
|
expect(config.mcpServers.gitnexus).toBeDefined();
|
|
});
|
|
|
|
it('leaves a corrupt mcp_config.json untouched rather than overwriting user data', async () => {
|
|
const mcpPath = path.join(antigravityDir, 'mcp_config.json');
|
|
const corrupt = '{ definitely not json !!!';
|
|
await fs.writeFile(mcpPath, corrupt, 'utf-8');
|
|
|
|
await setupCommand();
|
|
|
|
const raw = await fs.readFile(mcpPath, 'utf-8');
|
|
expect(raw).toBe(corrupt);
|
|
});
|
|
});
|