GitNexus/gitnexus/test/unit/skip-git-cli.test.ts
Copilot f350ae278a
feat: Add analyze --repair-fts, enforce FTS verification, and harden repair safeguards (#1720)
* Initial plan

* feat(analyze): add --repair-fts and verify FTS index rebuilds

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/dccb3673-af86-43aa-aede-2e1449399775

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* refactor(fts): tighten repair/verify messaging and option naming

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/dccb3673-af86-43aa-aede-2e1449399775

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* docs: highlight analyze --repair-fts vs --force in READMEs

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/61edc967-debc-419f-9f51-aebf2ef08d22

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* fix(analyze): guard repair mode against missing graph store

* fix(cli): reject --repair-fts with --force

* test(analyze): document repair-store fixture intent

* test(analyze): tidy repair failure fixtures and constants

* test(analyze): clarify mock constants in repair tests

* test(analyze): rename simulated missing-index constant

* test(analyze): clarify mocked graph shape in full-verify test

* refactor(analyze): finalize flag validation and test clarity

* test(skip-git): avoid hard failing when FTS extension is unavailable

* test(skip-git): log visible FTS-unavailable test skips

* test(skip-git): tighten FTS-unavailable error detection

* test(skip-git): simplify FTS-unavailable message checks

* test(skip-git): avoid HOME pointing at parent repo in fixture env

* fix(analyze): address Claude follow-up findings for repair guardrails

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/2f7243d3-ba16-4d83-86e5-17e6c58a3b0d

* fix(repair-fts): clarify invalid graph-store preflight errors

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/2f7243d3-ba16-4d83-86e5-17e6c58a3b0d

* test(analyze): strengthen assertions for conflict and missing-store errors

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/2f7243d3-ba16-4d83-86e5-17e6c58a3b0d

* fix(repair-fts): make invalid graph-store type errors explicit

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/2f7243d3-ba16-4d83-86e5-17e6c58a3b0d

* fix(repair-fts): improve graph-store type diagnostics

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/2f7243d3-ba16-4d83-86e5-17e6c58a3b0d

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-05-20 13:37:04 +01:00

374 lines
12 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import { execSync } from 'child_process';
import path from 'path';
import os from 'os';
import fs from 'fs';
describe('--skip-git CLI flag', () => {
const cliPath = path.resolve(__dirname, '../../dist/cli/index.js');
const ftsUnavailableMessage = 'FTS extension unavailable - cannot create FTS index';
interface ExecSyncLikeError {
message?: string;
stdout?: string | Buffer;
stderr?: string | Buffer;
}
const isFtsUnavailableError = (err: unknown): boolean => {
if (!err || typeof err !== 'object') return false;
const e = err as ExecSyncLikeError;
return (
e.message?.includes(ftsUnavailableMessage) ||
e.stdout?.toString().includes(ftsUnavailableMessage) ||
e.stderr?.toString().includes(ftsUnavailableMessage)
);
};
const shouldSkipForFtsUnavailable = (err: unknown, testName: string): boolean => {
if (!isFtsUnavailableError(err)) return false;
console.warn(
`[skip-git-cli.test] Skipping "${testName}" because FTS extension is unavailable.`,
);
return true;
};
it('Commander maps --skip-git to options.skipGit (not --no-git inversion)', () => {
// Verify the CLI defines --skip-git and --skip-agents-md in analyze help.
const helpOutput = execSync(`node "${cliPath}" analyze --help`, {
cwd: path.resolve(__dirname, '../..'),
encoding: 'utf8',
timeout: 10000,
});
expect(helpOutput).toContain('--skip-git');
expect(helpOutput).toContain('--skip-agents-md');
expect(helpOutput).toContain('--skip-skills');
expect(helpOutput).toContain('--index-only');
expect(helpOutput).not.toContain('--no-git');
});
it('warns when --index-only overrides --skills (PR 1485)', () => {
// `--index-only` suppresses the post-index skill step that `--skills`
// would otherwise trigger. Without an explicit warning, the user sees a
// pipeline re-index complete and silently no skill files written — the
// silent-contradiction case flagged in PR 1485 review.
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-index-only-skills-'));
const gitnexusHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-index-only-skills-home-'));
// Make tmpDir a git repo so analyze accepts it without --skip-git.
execSync('git init', { cwd: tmpDir, stdio: 'ignore' });
fs.writeFileSync(path.join(tmpDir, 'a.ts'), 'export const a = 1;\n');
const env = {
...process.env,
HOME: gitnexusHome,
GITNEXUS_HOME: gitnexusHome,
};
try {
let output: string;
try {
output = execSync(
`node "${cliPath}" analyze "${tmpDir}" --index-only --skills --skip-agents-md`,
{
encoding: 'utf8',
timeout: 60000,
env,
},
);
} catch (err: unknown) {
if (
shouldSkipForFtsUnavailable(err, 'warns when --index-only overrides --skills (PR 1485)')
)
return;
throw err;
}
expect(output).toContain('--index-only overrides --skills');
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
fs.rmSync(gitnexusHome, { recursive: true, force: true });
}
});
it('rejects non-git folder without --skip-git', () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-no-git-'));
fs.writeFileSync(path.join(tmpDir, 'test.ts'), 'export const x = 1;');
try {
execSync(`node dist/cli/index.js analyze "${tmpDir}"`, {
cwd: path.resolve(__dirname, '../..'),
encoding: 'utf8',
timeout: 10000,
});
// Should not reach here
expect.unreachable('Should have exited with non-zero');
} catch (err: any) {
expect(err.stdout || err.stderr || '').toContain('--skip-git');
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
it('still respects .gitnexusignore when run with --skip-git', () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-skip-git-ignore-'));
const gitnexusHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-skip-git-ignore-home-'));
fs.mkdirSync(path.join(tmpDir, 'src'), { recursive: true });
fs.mkdirSync(path.join(tmpDir, 'customskip'), { recursive: true });
fs.writeFileSync(path.join(tmpDir, '.gitnexusignore'), 'customskip/\n');
fs.writeFileSync(path.join(tmpDir, 'src', 'keep.ts'), 'export function keep() { return 1; }\n');
fs.writeFileSync(
path.join(tmpDir, 'customskip', 'leaked.ts'),
'export function leaked() { return 42; }\n',
);
const env = {
...process.env,
HOME: gitnexusHome,
GITNEXUS_HOME: gitnexusHome,
};
try {
try {
execSync(`node "${cliPath}" analyze "${tmpDir}" --skip-git --skip-agents-md`, {
encoding: 'utf8',
timeout: 60000,
env,
});
} catch (err: unknown) {
if (
shouldSkipForFtsUnavailable(
err,
'still respects .gitnexusignore when run with --skip-git',
)
)
return;
throw err;
}
const keepContext = execSync(
`node "${cliPath}" context keep --repo "${path.basename(tmpDir)}"`,
{
encoding: 'utf8',
timeout: 60000,
env,
},
);
expect(keepContext).toContain('"status": "found"');
expect(keepContext).toContain('"filePath": "src/keep.ts"');
const leakedContext = execSync(
`node "${cliPath}" context leaked --repo "${path.basename(tmpDir)}"`,
{
encoding: 'utf8',
timeout: 60000,
env,
},
);
expect(leakedContext).toContain(`"error": "Symbol 'leaked' not found"`);
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
fs.rmSync(gitnexusHome, { recursive: true, force: true });
}
});
describe('--skip-git does not walk up to parent git repo (#1232)', () => {
let parentDir: string;
let gitnexusHome: string;
function testEnv() {
return {
...process.env,
HOME: gitnexusHome,
GITNEXUS_HOME: gitnexusHome,
};
}
function readRegistry(): Array<{ name: string; path: string }> {
const registryPath = path.join(gitnexusHome, 'registry.json');
expect(fs.existsSync(registryPath)).toBe(true);
return JSON.parse(fs.readFileSync(registryPath, 'utf8'));
}
function canonicalPath(filePath: string): string {
return fs.realpathSync(filePath);
}
function expectCoolioRegistryEntry() {
const registry = readRegistry();
const entry = registry.find((e) => e.name === 'COOLIO');
expect(entry).toBeTruthy();
if (!entry) throw new Error('Expected COOLIO registry entry');
expect(canonicalPath(entry.path)).toBe(canonicalPath(path.join(parentDir, 'COOLIO')));
expect(
registry.find((e) => canonicalPath(e.path) === canonicalPath(parentDir)),
).toBeUndefined();
expect(
registry.find(
(e) => canonicalPath(e.path) === canonicalPath(path.join(parentDir, 'SubWooder')),
),
).toBeUndefined();
return entry;
}
function initParentGitRepo() {
execSync('git init', { cwd: parentDir, stdio: 'ignore' });
execSync(
'git -c user.name=test -c user.email=test@example.com commit --allow-empty -m init',
{
cwd: parentDir,
stdio: 'ignore',
},
);
}
function createTestStructure() {
// Create structure:
// parentDir/
// .git/ (parent is a git repo)
// COOLIO/
// package.json
// src/index.ts
// SubWooder/
// package.json
// src/index.ts
parentDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-skip-git-'));
gitnexusHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-skip-git-home-'));
initParentGitRepo();
fs.mkdirSync(path.join(parentDir, 'COOLIO', 'src'), { recursive: true });
fs.writeFileSync(
path.join(parentDir, 'COOLIO', 'package.json'),
JSON.stringify({ name: 'coolio' }),
);
fs.writeFileSync(
path.join(parentDir, 'COOLIO', 'src', 'index.ts'),
'export const hello = "world";',
);
fs.mkdirSync(path.join(parentDir, 'SubWooder', 'src'), { recursive: true });
fs.writeFileSync(
path.join(parentDir, 'SubWooder', 'package.json'),
JSON.stringify({ name: 'subwooder' }),
);
fs.writeFileSync(
path.join(parentDir, 'SubWooder', 'src', 'index.ts'),
'export const bass = 42;',
);
return parentDir;
}
function cleanup() {
if (parentDir) {
fs.rmSync(parentDir, { recursive: true, force: true });
}
if (gitnexusHome) {
fs.rmSync(gitnexusHome, { recursive: true, force: true });
}
}
it('from subdir inside parent git repo, indexes subdir not parent', () => {
createTestStructure();
try {
// Run analyze from COOLIO with --skip-git
let output: string;
try {
output = execSync(`node "${cliPath}" analyze --skip-git --skip-agents-md`, {
cwd: path.join(parentDir, 'COOLIO'),
encoding: 'utf8',
timeout: 60000,
env: testEnv(),
});
} catch (err: unknown) {
if (
shouldSkipForFtsUnavailable(
err,
'from subdir inside parent git repo, indexes subdir not parent',
)
)
return;
throw err;
}
// Should mention COOLIO not the parent dir name
expect(output).toContain('COOLIO');
expectCoolioRegistryEntry();
const siblingQuery = execSync(`node "${cliPath}" query bass --repo COOLIO`, {
cwd: path.join(parentDir, 'COOLIO'),
encoding: 'utf8',
timeout: 60000,
env: testEnv(),
});
expect(siblingQuery).not.toContain('SubWooder');
expect(siblingQuery).not.toContain('bass');
} finally {
cleanup();
}
});
it('keeps parent git status clean for --skip-git subdir analyze (#1233)', () => {
createTestStructure();
try {
fs.writeFileSync(path.join(parentDir, '.gitignore'), '.claude/\n');
execSync('git add .gitignore COOLIO SubWooder', { cwd: parentDir, stdio: 'ignore' });
execSync('git -c user.name=test -c user.email=test@example.com commit -m fixtures', {
cwd: parentDir,
stdio: 'ignore',
});
try {
execSync(`node "${cliPath}" analyze --skip-git --skip-agents-md`, {
cwd: path.join(parentDir, 'COOLIO'),
encoding: 'utf8',
timeout: 60000,
env: testEnv(),
});
} catch (err: unknown) {
if (
shouldSkipForFtsUnavailable(
err,
'keeps parent git status clean for --skip-git subdir analyze (#1233)',
)
)
return;
throw err;
}
expect(
fs.readFileSync(path.join(parentDir, 'COOLIO', '.gitnexus', '.gitignore'), 'utf8'),
).toBe('*\n');
const status = execSync('git status --short', {
cwd: parentDir,
encoding: 'utf8',
});
expect(status).toBe('');
} finally {
cleanup();
}
});
it('explicit input path with --skip-git indexes subdir', () => {
createTestStructure();
try {
let output: string;
try {
output = execSync(`node "${cliPath}" analyze ./COOLIO --skip-git --skip-agents-md`, {
cwd: parentDir,
encoding: 'utf8',
timeout: 60000,
env: testEnv(),
});
} catch (err: unknown) {
if (
shouldSkipForFtsUnavailable(err, 'explicit input path with --skip-git indexes subdir')
)
return;
throw err;
}
expect(output).toContain('COOLIO');
expectCoolioRegistryEntry();
} finally {
cleanup();
}
});
});
});