GitNexus/gitnexus/test/unit/setup-antigravity.test.ts
mengkaka 79543c8f83
feat(storage): add configurable index storage and content retention tiers (#3060)
* feat(storage): add configurable index storage and content retention tiers

Rebase #3060 onto current origin/main. Keep GITNEXUS_STORAGE_PATH,
GITNEXUS_STORAGE_ROOT, and GITNEXUS_CONTENT_RETENTION, and fold in
main's FTS skip, embed-session, and help-text updates.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3060)

Keep legacy registry rows on the local storage fallback, resolve
symlinks before the destructive-path guard, and align hook lookup
with CLI branch slugs, branch-slot metadata, and longest-path match.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3060)

Only list swept upload directories after a successful removal so
callers cannot treat a permission or transient rm failure as gone.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3060)

Document that getStoragePath may consult registered storage while
this module still does not mutate the global registry.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(storage): close review findings for external indexes and retention

Re-inspect ownership under the analyze lock, fail-closed when the
registry file is missing, and keep skip-git hook discovery plus
retention fields on HTTP/MCP list surfaces. /api/file stays 410
unless contentRetention is full.

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore(autofix): apply prettier + eslint fixes via /autofix command

* Address PR review feedback (#3060)

Treat lock-only index dirs as empty, honor HTTP --force storage policy, and prefer registered plus branch-aware slots in hooks and augment.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3060)

Keep hook fallbacks inside the current worktree, compare foreign-local slots canonically, and make storage fixtures survive ownership validation.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix macOS hook test expecting realpath'd registry paths.

resolveHookRepo returns the written registry path, not a filesystem realpath, so the assertion must match that.

* Address gitnexus-check warnings on hook install docs and slot tests.

The Cursor troubleshooting list omitted registry-query.cjs, and the writable-slot test only checked that isDirectory exists instead of that the path is a directory.

* Align the HTTP catalog source-scan with skippable resolveRepo validation.

resolveRepo lists fresh repos with validate: options.validateStorage !== false so DELETE can skip prune; the test still required a literal validate: true.

* Harden storage path sinks so CodeQL path-injection and ReDoS alerts clear.

Contain every filesystem probe inside the resolved storage slot with the inline path.relative idiom, reject filesystem-root slots, and trim slot basenames in linear time.

* Settle bridge stamps before writing so CI size/mtime matches stay stable.

LadybugDB can still flush into bridge.lbug after close+rename; persist whole-millisecond mtimes and wait for consecutive stats to agree so a freshly written pair matches.

* Type the settled bridge stat as fs.Stats so tsc does not see bigint.

Awaited<ReturnType<typeof fsp.stat>> collapsed the bigint overload and broke prepare/typecheck on CI.

* Keep the bridge mtime stamp exact so same-size swaps still fail the pair check.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Wrap the bridge stamp predicate so prettier --check stays green.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Require a quiet interval before stamping a settled bridge file.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Reuse shared storage and settle helpers instead of local copies.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-09-12 20:31:55 +00:00

642 lines
24 KiB
TypeScript

/**
* Regression Tests: Antigravity setup + hook adapter
*
* Covers:
* - setupAntigravity: detection of ~/.gemini/antigravity, MCP write, preserve
* existing keys, corrupt-file handling, skips when not installed.
* - installAntigravityHooks: writes ~/.gemini/settings.json with an
* AfterTool entry under the canonical Gemini CLI / Antigravity 2.0 layout
* (https://geminicli.com/docs/hooks/reference/); copies the adapter and
* lock helpers to ~/.gemini/config/hooks/gitnexus/; idempotent across
* re-runs; preserves existing user hooks ("polite neighbor").
* - installAntigravitySkills: lays out skills under ~/.gemini/antigravity/skills/.
* - hook adapter: AfterTool emits `{hookSpecificOutput.additionalContext}`
* with graph context after search-like tools; emits a stale-index hint
* after a successful `git commit/merge/rebase/cherry-pick/pull`; ignores
* unrelated tools silently.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import fs from 'fs/promises';
import os from 'os';
import path from 'path';
import { spawnSync } from 'child_process';
import { commitAll, initGitRepo } from '../helpers/temp-git-repo.js';
import { packageVersion } from '../../src/core/package-version.js';
const PKG_VERSION = packageVersion();
const NPX_REF = `gitnexus@${PKG_VERSION}`;
// vi.hoisted lets the mock factory below (which is hoisted by Vitest) see
// these vi.fn instances. Plain top-level consts would be unreachable at
// hoist time, hence the error this pattern avoids.
const mocks = vi.hoisted(() => ({
execFileMock: vi.fn((...args: any[]) => {
const callback = args.at(-1);
if (typeof callback === 'function') callback(null, '', '');
}),
execFileSyncMock: vi.fn(() => {
throw new Error('not found');
}),
}));
vi.mock('child_process', async () => {
// Partial mock: real spawnSync is needed for the hook-adapter tests below
// to actually invoke the .cjs script as a child process.
const actual = await vi.importActual<typeof import('child_process')>('child_process');
return {
...actual,
execFile: mocks.execFileMock,
execFileSync: mocks.execFileSyncMock,
};
});
describe('setupAntigravity', () => {
let tempHome: string;
let originalHome: string | undefined;
let originalUserProfile: string | undefined;
let platformDescriptor: PropertyDescriptor | undefined;
const setPlatform = (value: NodeJS.Platform) => {
Object.defineProperty(process, 'platform', {
value,
configurable: true,
});
};
const restoreSkillsRoot = (previous: string | undefined) => {
if (previous === undefined) delete process.env.GITNEXUS_TEST_SKILLS_ROOT;
else process.env.GITNEXUS_TEST_SKILLS_ROOT = previous;
};
beforeEach(async () => {
vi.resetModules();
vi.clearAllMocks();
originalHome = process.env.HOME;
originalUserProfile = process.env.USERPROFILE;
tempHome = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-antigravity-setup-'));
process.env.HOME = tempHome;
process.env.USERPROFILE = tempHome;
// Only create ~/.gemini/antigravity — no other editor dirs so their
// setup branches skip and don't pollute assertions.
await fs.mkdir(path.join(tempHome, '.gemini', 'antigravity'), { recursive: true });
platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
// Default to a non-win32 platform so the MCP entry shape is deterministic
// across CI runners. Tests that need win32 behavior override this.
setPlatform('darwin');
vi.spyOn(console, 'log').mockImplementation(() => {});
});
afterEach(async () => {
vi.restoreAllMocks();
if (platformDescriptor) {
Object.defineProperty(process, 'platform', platformDescriptor);
}
process.env.HOME = originalHome;
process.env.USERPROFILE = originalUserProfile;
await fs.rm(tempHome, { recursive: true, force: true });
});
it('writes MCP config to ~/.gemini/antigravity/mcp_config.json', async () => {
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
const raw = await fs.readFile(
path.join(tempHome, '.gemini', 'antigravity', 'mcp_config.json'),
'utf-8',
);
const config = JSON.parse(raw);
expect(config.mcpServers.gitnexus).toEqual({
command: 'npx',
args: ['-y', NPX_REF, 'mcp'],
});
});
it('writes win32 MCP entry with cmd wrapper', async () => {
setPlatform('win32');
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
const raw = await fs.readFile(
path.join(tempHome, '.gemini', 'antigravity', 'mcp_config.json'),
'utf-8',
);
const config = JSON.parse(raw);
expect(config.mcpServers.gitnexus).toEqual({
command: 'cmd',
args: ['/c', 'npx', '-y', NPX_REF, 'mcp'],
});
});
it('skips when ~/.gemini/antigravity does not exist', async () => {
await fs.rm(path.join(tempHome, '.gemini'), { recursive: true, force: true });
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
await expect(
fs.access(path.join(tempHome, '.gemini', 'antigravity', 'mcp_config.json')),
).rejects.toThrow();
await expect(fs.access(path.join(tempHome, '.gemini', 'settings.json'))).rejects.toThrow();
});
it('preserves existing keys in mcp_config.json', async () => {
const mcpPath = path.join(tempHome, '.gemini', 'antigravity', 'mcp_config.json');
await fs.writeFile(
mcpPath,
JSON.stringify({ existingKey: 'keep-me', mcpServers: { other: { command: 'foo' } } }),
'utf-8',
);
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
const raw = await fs.readFile(mcpPath, 'utf-8');
const config = JSON.parse(raw);
expect(config.existingKey).toBe('keep-me');
expect(config.mcpServers.other).toEqual({ command: 'foo' });
expect(config.mcpServers.gitnexus).toBeDefined();
});
it('leaves a corrupt mcp_config.json untouched', async () => {
const mcpPath = path.join(tempHome, '.gemini', 'antigravity', 'mcp_config.json');
const corrupt = '{ definitely not json !!!';
await fs.writeFile(mcpPath, corrupt, 'utf-8');
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
const raw = await fs.readFile(mcpPath, 'utf-8');
expect(raw).toBe(corrupt);
});
it('writes ~/.gemini/settings.json with an AfterTool entry under hooks', async () => {
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
const raw = await fs.readFile(path.join(tempHome, '.gemini', 'settings.json'), 'utf-8');
const config = JSON.parse(raw);
expect(config.hooks.AfterTool).toBeInstanceOf(Array);
expect(config.hooks.AfterTool[0].matcher).toBe('search_file_content|glob|run_shell_command');
expect(config.hooks.AfterTool[0].hooks[0].command).toMatch(/gitnexus-antigravity-hook\.cjs/);
expect(config.hooks.AfterTool[0].hooks[0].timeout).toBe(10000);
expect(config.hooks.AfterTool[0].hooks[0].name).toBe('gitnexus');
});
it('is idempotent — re-running setup does not duplicate hook entries', async () => {
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
await setupCommand();
const raw = await fs.readFile(path.join(tempHome, '.gemini', 'settings.json'), 'utf-8');
const config = JSON.parse(raw);
expect(config.hooks.AfterTool).toHaveLength(1);
});
it('preserves existing user hooks in settings.json (polite-neighbor merge)', async () => {
const settingsPath = path.join(tempHome, '.gemini', 'settings.json');
await fs.writeFile(
settingsPath,
JSON.stringify({
theme: 'dark',
hooks: {
AfterTool: [
{
matcher: 'write_file',
hooks: [{ type: 'command', command: 'echo "user-hook"', name: 'user-formatter' }],
},
],
},
}),
'utf-8',
);
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
const raw = await fs.readFile(settingsPath, 'utf-8');
const config = JSON.parse(raw);
// Unrelated keys preserved
expect(config.theme).toBe('dark');
// User's hook still present
expect(config.hooks.AfterTool).toHaveLength(2);
expect(config.hooks.AfterTool[0].hooks[0].command).toBe('echo "user-hook"');
// Our entry appended after, not replacing
expect(config.hooks.AfterTool[1].hooks[0].command).toMatch(/gitnexus-antigravity-hook\.cjs/);
});
it('copies adapter + lock helpers to ~/.gemini/config/hooks/gitnexus/', async () => {
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
const destDir = path.join(tempHome, '.gemini', 'config', 'hooks', 'gitnexus');
await expect(
fs.access(path.join(destDir, 'gitnexus-antigravity-hook.cjs')),
).resolves.toBeUndefined();
await expect(fs.access(path.join(destDir, 'hook-lock.cjs'))).resolves.toBeUndefined();
await expect(fs.access(path.join(destDir, 'hook-db-lock-probe.cjs'))).resolves.toBeUndefined();
// Required by hook-db-lock-probe.cjs on Windows; without it the MCP
// server ownership probe silently fails open.
await expect(fs.access(path.join(destDir, 'win-rm-list-json.ps1'))).resolves.toBeUndefined();
// The adapter top-level require()s this; the production install path must
// co-locate it next to the adapter (symmetric with the Claude install).
await expect(fs.access(path.join(destDir, 'resolve-analyze-cmd.cjs'))).resolves.toBeUndefined();
await expect(fs.access(path.join(destDir, 'registry-query.cjs'))).resolves.toBeUndefined();
});
it('installs skills under ~/.gemini/antigravity/skills/<name>/SKILL.md', async () => {
// Stage a fixture skills tree so the assertion does not depend on
// installSkillsTo's __dirname resolution (which is brittle under
// Vitest on Windows). Production reads the real gitnexus/skills/ dir.
const fixtureSkillsRoot = path.join(tempHome, 'fixture-skills');
await fs.mkdir(fixtureSkillsRoot, { recursive: true });
await fs.writeFile(
path.join(fixtureSkillsRoot, 'gitnexus-test.md'),
'---\nname: gitnexus-test\ndescription: fixture\n---\nbody\n',
'utf-8',
);
const originalSkillsRoot = process.env.GITNEXUS_TEST_SKILLS_ROOT;
process.env.GITNEXUS_TEST_SKILLS_ROOT = fixtureSkillsRoot;
try {
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
const skillsDir = path.join(tempHome, '.gemini', 'antigravity', 'skills');
const entries = await fs.readdir(skillsDir, { withFileTypes: true });
const skillDirs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
expect(skillDirs).toContain('gitnexus-test');
await expect(
fs.access(path.join(skillsDir, 'gitnexus-test', 'SKILL.md')),
).resolves.toBeUndefined();
} finally {
restoreSkillsRoot(originalSkillsRoot);
}
});
it('preserves a customized installed skill when setup is rerun', async () => {
const fixtureSkillsRoot = path.join(tempHome, 'fixture-skills');
const installedSkill = path.join(
tempHome,
'.gemini',
'antigravity',
'skills',
'gitnexus-test',
'SKILL.md',
);
await fs.mkdir(fixtureSkillsRoot, { recursive: true });
await fs.writeFile(
path.join(fixtureSkillsRoot, 'gitnexus-test.md'),
'---\nname: gitnexus-test\ndescription: fixture\n---\nbody\n',
'utf-8',
);
const originalSkillsRoot = process.env.GITNEXUS_TEST_SKILLS_ROOT;
process.env.GITNEXUS_TEST_SKILLS_ROOT = fixtureSkillsRoot;
try {
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
await fs.writeFile(installedSkill, 'customized by operator\n', 'utf-8');
await setupCommand();
await expect(fs.readFile(installedSkill, 'utf-8')).resolves.toBe('customized by operator\n');
} finally {
restoreSkillsRoot(originalSkillsRoot);
}
});
it('preserves customized files inside a directory skill when SKILL.md still matches', async () => {
const fixtureSkillsRoot = path.join(tempHome, 'fixture-skills');
const skillDir = path.join(tempHome, '.gemini', 'antigravity', 'skills', 'gitnexus-test');
const referencePath = path.join(skillDir, 'references', 'note.md');
await fs.mkdir(path.join(fixtureSkillsRoot, 'gitnexus-test', 'references'), {
recursive: true,
});
await fs.writeFile(
path.join(fixtureSkillsRoot, 'gitnexus-test', 'SKILL.md'),
'---\nname: gitnexus-test\ndescription: fixture\n---\nbody\n',
'utf-8',
);
await fs.writeFile(
path.join(fixtureSkillsRoot, 'gitnexus-test', 'references', 'note.md'),
'bundled note\n',
'utf-8',
);
const originalSkillsRoot = process.env.GITNEXUS_TEST_SKILLS_ROOT;
process.env.GITNEXUS_TEST_SKILLS_ROOT = fixtureSkillsRoot;
try {
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
await fs.writeFile(referencePath, 'operator note\n', 'utf-8');
await setupCommand();
await expect(fs.readFile(referencePath, 'utf-8')).resolves.toBe('operator note\n');
await expect(fs.readFile(path.join(skillDir, 'SKILL.md'), 'utf-8')).resolves.toBe(
'---\nname: gitnexus-test\ndescription: fixture\n---\nbody\n',
);
} finally {
restoreSkillsRoot(originalSkillsRoot);
}
});
it('copies new bundled companions even when SKILL.md was customized', async () => {
const fixtureSkillsRoot = path.join(tempHome, 'fixture-skills');
const skillDir = path.join(tempHome, '.gemini', 'antigravity', 'skills', 'gitnexus-test');
await fs.mkdir(path.join(fixtureSkillsRoot, 'gitnexus-test', 'references'), {
recursive: true,
});
await fs.writeFile(
path.join(fixtureSkillsRoot, 'gitnexus-test', 'SKILL.md'),
'---\nname: gitnexus-test\ndescription: fixture\n---\nbody\n',
'utf-8',
);
await fs.writeFile(
path.join(fixtureSkillsRoot, 'gitnexus-test', 'references', 'note.md'),
'bundled note\n',
'utf-8',
);
const originalSkillsRoot = process.env.GITNEXUS_TEST_SKILLS_ROOT;
process.env.GITNEXUS_TEST_SKILLS_ROOT = fixtureSkillsRoot;
try {
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
await fs.writeFile(path.join(skillDir, 'SKILL.md'), 'customized by operator\n', 'utf-8');
await fs.writeFile(
path.join(fixtureSkillsRoot, 'gitnexus-test', 'references', 'added.md'),
'new bundled companion\n',
'utf-8',
);
await setupCommand();
await expect(fs.readFile(path.join(skillDir, 'SKILL.md'), 'utf-8')).resolves.toBe(
'customized by operator\n',
);
await expect(
fs.readFile(path.join(skillDir, 'references', 'added.md'), 'utf-8'),
).resolves.toBe('new bundled companion\n');
} finally {
restoreSkillsRoot(originalSkillsRoot);
}
});
});
// ─── Hook adapter smoke tests ──────────────────────────────────────
//
// The adapter relies on sibling helpers (hook-lock.cjs, hook-db-lock-probe.cjs).
// For tests we lay out a self-contained copy in a temp dir and spawn it.
const PROJECT_ROOT = path.resolve(__dirname, '..', '..');
const ADAPTER_SRC = path.join(
PROJECT_ROOT,
'hooks',
'antigravity',
'gitnexus-antigravity-hook.cjs',
);
const LOCK_SRC = path.join(PROJECT_ROOT, 'hooks', 'claude', 'hook-lock.cjs');
const PROBE_SRC = path.join(PROJECT_ROOT, 'hooks', 'claude', 'hook-db-lock-probe.cjs');
const WIN_RM_SRC = path.join(PROJECT_ROOT, 'hooks', 'claude', 'win-rm-list-json.ps1');
const RESOLVE_SRC = path.join(PROJECT_ROOT, 'hooks', 'claude', 'resolve-analyze-cmd.cjs');
const REGISTRY_QUERY_SRC = path.join(PROJECT_ROOT, 'hooks', 'claude', 'registry-query.cjs');
async function stageAdapter(): Promise<string> {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-antigravity-adapter-'));
await fs.copyFile(ADAPTER_SRC, path.join(tmp, 'gitnexus-antigravity-hook.cjs'));
await fs.copyFile(LOCK_SRC, path.join(tmp, 'hook-lock.cjs'));
await fs.copyFile(PROBE_SRC, path.join(tmp, 'hook-db-lock-probe.cjs'));
// hook-db-lock-probe.cjs loads this PowerShell script on Windows; without it,
// the lock probe silently fails open and the adapter's Windows DB-lock path
// would be untested in child-process smoke tests.
await fs.copyFile(WIN_RM_SRC, path.join(tmp, 'win-rm-list-json.ps1'));
// The adapter top-level `require('./resolve-analyze-cmd.cjs')`s this helper;
// without staging it the spawned adapter crashes with MODULE_NOT_FOUND.
await fs.copyFile(RESOLVE_SRC, path.join(tmp, 'resolve-analyze-cmd.cjs'));
await fs.copyFile(REGISTRY_QUERY_SRC, path.join(tmp, 'registry-query.cjs'));
return path.join(tmp, 'gitnexus-antigravity-hook.cjs');
}
function runAdapter(
hookPath: string,
input: Record<string, any>,
cwd?: string,
env?: Record<string, string>,
): { stdout: string; stderr: string; status: number | null } {
const result = spawnSync(process.execPath, [hookPath], {
input: JSON.stringify(input),
encoding: 'utf-8',
timeout: 10000,
cwd,
env: env ? { ...process.env, ...env } : process.env,
stdio: ['pipe', 'pipe', 'pipe'],
});
return { stdout: result.stdout || '', stderr: result.stderr || '', status: result.status };
}
// A staged adapter that fails to load (e.g. a missing sibling helper) exits
// non-zero and prints MODULE_NOT_FOUND — a state that otherwise masquerades as
// "no stdout" in the silent-path tests below. Assert the process actually ran.
function expectAdapterLoaded(stderr: string, status: number | null): void {
expect(status).toBe(0);
expect(stderr).not.toMatch(/MODULE_NOT_FOUND|Cannot find module/);
}
describe('gitnexus-antigravity-hook adapter', () => {
let adapter: string;
let workdir: string;
let registryHome: string;
beforeEach(async () => {
adapter = await stageAdapter();
workdir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-antigravity-work-'));
registryHome = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-antigravity-registry-'));
await fs.writeFile(path.join(registryHome, 'registry.json'), '[]\n', 'utf-8');
});
afterEach(async () => {
await fs.rm(path.dirname(adapter), { recursive: true, force: true });
await fs.rm(workdir, { recursive: true, force: true });
await fs.rm(registryHome, { recursive: true, force: true });
});
async function registerWorkdir(storagePath: string): Promise<void> {
await fs.writeFile(
path.join(registryHome, 'registry.json'),
JSON.stringify([{ name: 'adapter-test', path: workdir, storagePath }]),
'utf-8',
);
}
it('AfterTool with no .gitnexus/ produces no stdout', async () => {
const { stdout, stderr, status } = runAdapter(
adapter,
{
hook_event_name: 'AfterTool',
tool_name: 'search_file_content',
tool_input: { pattern: 'someSymbol' },
tool_response: { llmContent: '...' },
cwd: workdir,
},
workdir,
);
expect(stdout.trim()).toBe('');
expectAdapterLoaded(stderr, status);
});
it('AfterTool ignores unrelated tools silently', async () => {
const { stdout, stderr, status } = runAdapter(
adapter,
{
hook_event_name: 'AfterTool',
tool_name: 'read_file',
tool_input: { path: 'README.md' },
tool_response: { llmContent: '...' },
cwd: workdir,
},
workdir,
);
expect(stdout.trim()).toBe('');
expect(stderr).not.toMatch(/\[GitNexus\]/);
expectAdapterLoaded(stderr, status);
});
it('AfterTool ignores non-git run_shell_command silently', async () => {
const gnDir = path.join(workdir, '.gitnexus');
await fs.mkdir(gnDir, { recursive: true });
await fs.writeFile(
path.join(gnDir, 'meta.json'),
JSON.stringify({ lastCommit: 'deadbeef', stats: {} }),
'utf-8',
);
const { stdout, stderr, status } = runAdapter(
adapter,
{
hook_event_name: 'AfterTool',
tool_name: 'run_shell_command',
tool_input: { command: 'npm test' },
tool_response: { llmContent: '...' },
cwd: workdir,
},
workdir,
);
expect(stdout.trim()).toBe('');
expect(stderr).not.toMatch(/\[GitNexus\]/);
expectAdapterLoaded(stderr, status);
});
it('AfterTool emits stale-index hint after a successful git commit', async () => {
// Initialize a git repo and a stale .gitnexus/meta.json.
initGitRepo(workdir);
await fs.writeFile(path.join(workdir, 'a.txt'), 'hello', 'utf-8');
commitAll(workdir, 'init');
const gnDir = path.join(workdir, '.gitnexus');
await fs.mkdir(gnDir, { recursive: true });
await fs.writeFile(
path.join(gnDir, 'meta.json'),
JSON.stringify({ lastCommit: '0000000000000000000000000000000000000000', stats: {} }),
'utf-8',
);
await registerWorkdir(gnDir);
const input = {
hook_event_name: 'AfterTool',
tool_name: 'run_shell_command',
tool_input: { command: 'git commit -m "x"' },
tool_response: { llmContent: '[committed]' },
cwd: workdir,
};
// Force a deterministic invocation mode: the emitted analyze command varies
// by what's installed on each CI runner (gitnexus/pnpm/npx); only the
// `gitnexus` mode yields the bare `gitnexus analyze` form.
const { stdout, stderr } = runAdapter(adapter, input, workdir, {
GITNEXUS_INVOCATION: 'gitnexus',
GITNEXUS_DEBUG: '',
GITNEXUS_HOME: registryHome,
});
// #1913: by default the hint reaches the agent via additionalContext (stdout
// JSON) but is NOT mirrored to stderr, so strict hook runners stay clean.
const parsed = JSON.parse(stdout);
expect(parsed.hookSpecificOutput.hookEventName).toBe('AfterTool');
expect(parsed.hookSpecificOutput.additionalContext).toMatch(/index is stale/);
expect(parsed.hookSpecificOutput.additionalContext).toMatch(/gitnexus analyze/);
expect(stderr).not.toMatch(/\[GitNexus\] index is stale/);
// The terminal mirror remains available under GITNEXUS_DEBUG=1.
const debug = runAdapter(adapter, input, workdir, {
GITNEXUS_INVOCATION: 'gitnexus',
GITNEXUS_DEBUG: '1',
GITNEXUS_HOME: registryHome,
});
expect(debug.stderr).toMatch(/\[GitNexus\] index is stale/);
expect(debug.stderr).toMatch(/gitnexus analyze/);
});
it('AfterTool skips augment when the tool failed', async () => {
const gnDir = path.join(workdir, '.gitnexus');
await fs.mkdir(gnDir, { recursive: true });
await fs.writeFile(
path.join(gnDir, 'meta.json'),
JSON.stringify({ lastCommit: 'deadbeef', stats: {} }),
'utf-8',
);
const { stdout, stderr, status } = runAdapter(
adapter,
{
hook_event_name: 'AfterTool',
tool_name: 'search_file_content',
tool_input: { pattern: 'someSymbol' },
tool_response: { error: 'boom' },
cwd: workdir,
},
workdir,
);
expect(stdout.trim()).toBe('');
expectAdapterLoaded(stderr, status);
});
it('ignores unknown tool names without crashing', async () => {
const { status } = runAdapter(
adapter,
{
hook_event_name: 'AfterTool',
tool_name: 'unknown_tool',
tool_input: {},
tool_response: { llmContent: '' },
cwd: workdir,
},
workdir,
);
expect(status).toBe(0);
});
it('does not crash on empty stdin', () => {
const result = spawnSync(process.execPath, [adapter], {
input: '',
encoding: 'utf-8',
timeout: 10000,
stdio: ['pipe', 'pipe', 'pipe'],
});
expect(result.status).toBe(0);
});
});