GitNexus/gitnexus/test/unit/cli-index-help.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

423 lines
17 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { spawnSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { Command, Option } from 'commander';
import * as ts from 'typescript';
import { afterEach, describe, expect, it } from 'vitest';
import { CLI_SPAWN_PREFIX } from '../helpers/cli-entry.js';
import { localizeCliHelp } from '../../src/cli/help-i18n.js';
import { setCliLanguage, type SupportedCliLanguage } from '../../src/cli/i18n/index.js';
const testDir = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(testDir, '../..');
function runHelp(command: string, env: NodeJS.ProcessEnv = {}) {
return runHelpArgs([command], env);
}
function runHelpArgs(args: string[], env: NodeJS.ProcessEnv = {}) {
return runCliArgs([...args, '--help'], env);
}
function runCliArgs(args: string[], env: NodeJS.ProcessEnv = {}) {
return spawnSync(process.execPath, [...CLI_SPAWN_PREFIX, ...args], {
cwd: repoRoot,
encoding: 'utf8',
env: { ...process.env, ...env },
});
}
function runRootHelp(env: NodeJS.ProcessEnv = {}) {
return runHelpArgs([], env);
}
const allHelpCommands = [
[],
['setup'],
['analyze'],
['index'],
['serve'],
['mcp'],
['list'],
['status'],
['doctor'],
['update'],
['clean'],
['remove'],
['wiki'],
['augment'],
['publish'],
['query'],
['context'],
['impact'],
['cypher'],
['detect-changes'],
['eval-server'],
['embeddings'],
['embeddings', 'install'],
['embeddings', 'sync'],
['group'],
['group', 'create'],
['group', 'add'],
['group', 'remove'],
['group', 'list'],
['group', 'status'],
['group', 'sync'],
['group', 'impact'],
['group', 'query'],
['group', 'contracts'],
];
function staticStringValue(node: ts.Node | undefined): string | undefined {
if (!node) return undefined;
if (ts.isStringLiteralLike(node) || ts.isNoSubstitutionTemplateLiteral(node)) return node.text;
if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.PlusToken) {
const left = staticStringValue(node.left);
const right = staticStringValue(node.right);
if (left !== undefined && right !== undefined) return `${left}${right}`;
}
return undefined;
}
function extractRegisteredHelpDescriptions(): string[] {
const descriptions = new Set<string>();
const sourceFiles = ['src/cli/index.ts', 'src/cli/group.ts'];
for (const relativePath of sourceFiles) {
const filePath = path.join(repoRoot, relativePath);
const source = fs.readFileSync(filePath, 'utf8');
const sourceFile = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true);
function visit(node: ts.Node): void {
if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression)) {
const method = node.expression.name.text;
const description =
method === 'description'
? staticStringValue(node.arguments[0])
: method === 'option' || method === 'requiredOption'
? staticStringValue(node.arguments[1])
: undefined;
if (description && /[A-Za-z]/.test(description)) {
descriptions.add(description.replace(/\s+/g, ' ').trim());
}
}
ts.forEachChild(node, visit);
}
visit(sourceFile);
}
return [...descriptions].filter((description) => description.length > 0).sort();
}
function metadataHelp(language: SupportedCliLanguage) {
setCliLanguage(language);
const command = new Command('probe');
command.addOption(new Option('--mode <mode>', 'Mode').choices(['fast', 'safe']));
command.addOption(new Option('--limit <n>', 'Limit').default('5'));
command.addOption(new Option('--level [name]', 'Level').preset('auto'));
command.addOption(new Option('--token <token>', 'Token').env('GITNEXUS_TOKEN'));
localizeCliHelp(command);
return command.helpInformation();
}
describe('CLI help surface', () => {
afterEach(() => setCliLanguage(null));
it('root help localizes commander headings, options, and command descriptions', () => {
const result = runRootHelp({ GITNEXUS_LANG: 'zh-CN' } as NodeJS.ProcessEnv);
expect(result.status).toBe(0);
expect(result.stdout).toContain('用法: gitnexus [options] [command]');
expect(result.stdout).toContain('GitNexus 本地 CLI 和 MCP 服务器');
expect(result.stdout).toContain('选项:');
expect(result.stdout).toContain('-V, --version 输出版本号');
expect(result.stdout).toContain('-h, --help 显示命令帮助');
expect(result.stdout).toContain('命令:');
expect(result.stdout).toContain('setup');
// Stable fragments rather than the full editor roster: the roster grows
// over time (see PR #2368), and the dynamic test below ("localizes every
// registered CLI command...") already fails on any untranslated
// description, so freezing the roster here only creates churn.
expect(result.stdout).toContain('一次性设置');
expect(result.stdout).toContain('配置 MCP');
expect(result.stdout).toContain('detect-changes|detect_changes [options]');
expect(result.stdout).toContain('将 git diff hunk 映射到已索引符号和受影响执行流程');
expect(result.stdout).not.toContain('GitNexus local CLI and MCP server');
expect(result.stdout).not.toContain('display help for command');
});
it('command help localizes option descriptions and help suffix text', () => {
const result = runHelp('query', { GITNEXUS_LANG: 'zh-CN' } as NodeJS.ProcessEnv);
expect(result.status).toBe(0);
expect(result.stdout).toContain('用法: gitnexus query [options] [search_query]');
expect(result.stdout).toContain('搜索知识图谱中与概念相关的执行流程');
expect(result.stdout).toContain('-r, --repo <name> 目标仓库(仅有一个已索引仓库时可省略)');
expect(result.stdout).toContain('-l, --limit <n> 最多返回的流程数默认5');
expect(result.stdout).toContain('-h, --help 显示命令帮助');
expect(result.stdout).not.toContain('Target repository (omit if only one indexed)');
});
it('setup help exposes selective coding-agent configuration', () => {
const result = runHelp('setup');
expect(result.status).toBe(0);
expect(result.stdout).toContain('gitnexus setup [options]');
expect(result.stdout).toContain('-c, --coding-agent <agents>');
});
it('localizes every registered CLI command and option description in zh-CN help', () => {
const zhHelpOutput = allHelpCommands
.map((args) => {
const result = runHelpArgs(args, { GITNEXUS_LANG: 'zh-CN' } as NodeJS.ProcessEnv);
expect(result.status, `gitnexus ${args.join(' ')} --help`).toBe(0);
return result.stdout;
})
.join('\n');
const untranslated = extractRegisteredHelpDescriptions().filter((description) =>
zhHelpOutput.includes(description),
);
expect(untranslated).toEqual([]);
});
it('analyze help localizes custom environment variable help text', () => {
const result = runHelp('analyze', { GITNEXUS_LANG: 'zh-CN' } as NodeJS.ProcessEnv);
expect(result.status).toBe(0);
expect(result.stdout).toContain('环境变量:');
expect(result.stdout).toContain('GITNEXUS_STORAGE_PATH=/absolute/index');
expect(result.stdout).toContain('完整外部索引目录');
expect(result.stdout).toContain('GITNEXUS_STORAGE_ROOT=/absolute/root');
expect(result.stdout).toContain('外部索引根目录');
expect(result.stdout).toContain('GITNEXUS_CONTENT_RETENTION=full');
expect(result.stdout).toContain('源码文本保留策略');
expect(result.stdout).toContain('当参数和对应环境变量同时提供时,参数优先。');
expect(result.stdout).toContain('提示:`.gitnexusignore` 支持 `.gitignore` 风格的取反。');
expect(result.stdout).not.toContain('Environment variables:');
expect(result.stdout).not.toContain('Flags override the corresponding env vars');
});
it('analyze help documents the external storage root layout', () => {
const result = runHelp('analyze');
expect(result.status).toBe(0);
expect(result.stdout).toContain('GITNEXUS_STORAGE_PATH=/absolute/index');
expect(result.stdout).toContain('Complete external index directory');
expect(result.stdout).toContain('GITNEXUS_STORAGE_ROOT=/absolute/root');
expect(result.stdout).toContain('External index root');
expect(result.stdout).toContain('GITNEXUS_CONTENT_RETENTION=full');
expect(result.stdout).toContain('Source-text retention profile');
expect(result.stdout).toContain('<repo-basename>-<canonical-path-hash>/');
});
it('query help keeps advanced search options without importing analyze deps', () => {
const result = runHelp('query');
expect(result.status).toBe(0);
expect(result.stdout).toContain('--context <text>');
expect(result.stdout).toContain('--goal <text>');
expect(result.stdout).toContain('--content');
expect(result.stderr).not.toContain('tree-sitter-kotlin');
});
it('context help keeps optional name and disambiguation flags', () => {
const result = runHelp('context');
expect(result.status).toBe(0);
expect(result.stdout).toContain('context [options] [name]');
expect(result.stdout).toContain('--uid <uid>');
expect(result.stdout).toContain('--file <path>');
});
it('impact help keeps repo, include-tests, and disambiguation flags', () => {
const result = runHelp('impact');
expect(result.status).toBe(0);
expect(result.stdout).toContain('--depth <n>');
expect(result.stdout).toContain('--include-tests');
expect(result.stdout).toContain('--repo <name>');
// Disambiguation flags (#1907) — mirror the context help test so a
// missing-flag regression on impact is caught here too.
expect(result.stdout).toContain('--uid <uid>');
expect(result.stdout).toContain('--file <path>');
expect(result.stdout).toContain('--kind <kind>');
});
it('detect-changes help exposes compare scope and base-ref flags', () => {
const result = runHelp('detect-changes');
expect(result.status).toBe(0);
expect(result.stdout).toContain('gitnexus detect-changes|detect_changes [options]');
expect(result.stdout).toContain('--scope <scope>');
expect(result.stdout).toContain('--base-ref <ref>');
expect(result.stdout).toContain('--repo <name>');
});
it('query-family commands expose the --branch scope flag (#2106)', () => {
for (const cmd of ['query', 'context', 'impact', 'cypher', 'detect-changes']) {
const result = runHelp(cmd);
expect(result.status, cmd).toBe(0);
expect(result.stdout, cmd).toContain('--branch <name>');
}
});
it('auto-sync help exposes lifecycle actions and state files', () => {
const result = runHelp('auto-sync');
expect(result.status).toBe(0);
expect(result.stdout).toContain('gitnexus auto-sync [options] [action]');
expect(result.stdout).toContain('Actions: init, start (default), restart, stop, status, reset');
expect(result.stdout).toContain('GITNEXUS_HOME/watch_config.yml');
expect(result.stdout).toContain('GITNEXUS_HOME/watch/watch.pid');
expect(result.stdout).toContain('GITNEXUS_HOME/watch/project_commit_info.txt');
});
it('watch is reserved and does not start auto-sync or local watch', () => {
const help = runHelp('watch');
expect(help.status).toBe(0);
expect(help.stdout).toContain('gitnexus watch [options] [action]');
expect(help.stdout).toContain('gitnexus analyze --watch');
expect(help.stdout).toContain('gitnexus auto-sync start');
expect(help.stdout).not.toContain('GITNEXUS_HOME/watch_config.yml');
const started = runCliArgs(['watch'], {});
expect(started.status).toBe(1);
expect(started.stderr).toContain('gitnexus watch');
expect(started.stderr).toContain('gitnexus analyze --watch');
expect(started.stderr).toContain('gitnexus auto-sync start');
const startAction = runCliArgs(['watch', 'start'], {});
expect(startAction.status).toBe(1);
expect(startAction.stderr).toContain('gitnexus auto-sync start');
});
it('auto-sync init creates the default watch_config.yml and does not overwrite it', () => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-watch-init-'));
try {
const first = runCliArgs(['auto-sync', 'init'], { GITNEXUS_HOME: home });
const configPath = path.join(home, 'watch_config.yml');
expect(first.status).toBe(0);
expect(first.stdout).toContain(`Created ${configPath}`);
const config = fs.readFileSync(configPath, 'utf8');
expect(config).toContain('sync_interval_minutes: 10');
expect(config).toContain('analyze_failure_threshold: 3');
expect(config).toContain('analyze_timeout: 5m');
expect(config).toContain('overwrite_local_changes: false');
expect(config).toContain(`local_path: ${path.join(home, 'repos')}`);
expect(config).not.toContain('/abs/path/to/repos');
expect(config).toContain('git@github.com:owner/repo.git');
expect(config).not.toContain('group_name:');
const second = runCliArgs(['auto-sync', 'init'], { GITNEXUS_HOME: home });
expect(second.status).toBe(1);
expect(second.stderr).toContain(`Config already exists: ${configPath}`);
expect(fs.readFileSync(configPath, 'utf8')).toBe(config);
} finally {
fs.rmSync(home, { recursive: true, force: true });
}
});
it('auto-sync reset removes only derived auto-sync state files', () => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-watch-reset-'));
const watchDir = path.join(home, 'watch');
const cloneMarker = path.join(home, 'repos', 'repo', 'keep.txt');
try {
fs.mkdirSync(path.dirname(cloneMarker), { recursive: true });
fs.writeFileSync(cloneMarker, 'keep');
fs.mkdirSync(watchDir, { recursive: true });
fs.writeFileSync(path.join(watchDir, 'auto-sync-state.json'), '{}');
fs.writeFileSync(path.join(watchDir, 'project_commit_info.txt'), 'derived');
const result = runCliArgs(['auto-sync', 'reset'], { GITNEXUS_HOME: home });
expect(result.status).toBe(0);
expect(result.stdout).toContain('Reset analysis state');
expect(fs.existsSync(path.join(watchDir, 'auto-sync-state.json'))).toBe(false);
expect(fs.existsSync(path.join(watchDir, 'project_commit_info.txt'))).toBe(false);
expect(fs.readFileSync(cloneMarker, 'utf8')).toBe('keep');
} finally {
fs.rmSync(home, { recursive: true, force: true });
}
});
it('auto-sync stop exits non-zero when no watch was stopped', () => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-watch-stop-'));
try {
const result = runCliArgs(['auto-sync', 'stop'], { GITNEXUS_HOME: home });
expect(result.status).toBe(1);
expect(result.stderr).toContain('Watch is not running');
} finally {
fs.rmSync(home, { recursive: true, force: true });
}
});
it('auto-sync restart starts when the watch is not running', () => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-watch-restart-'));
try {
const result = runCliArgs(['auto-sync', 'restart'], { GITNEXUS_HOME: home });
expect(result.status).toBe(1);
expect(result.stderr).toContain('Watch is not running');
expect(result.stderr).toContain('Missing config file');
} finally {
fs.rmSync(home, { recursive: true, force: true });
}
});
it('wiki help shows provider, review, and verbose flags', () => {
const result = runHelp('wiki');
expect(result.status).toBe(0);
expect(result.stdout).toContain('--provider <provider>');
expect(result.stdout).toContain('claude');
expect(result.stdout).toContain('codex');
expect(result.stdout).toContain('grok');
expect(result.stdout).toContain('--review');
expect(result.stdout).toContain('-v, --verbose');
expect(result.stdout).toContain('--model <model>');
expect(result.stdout).toContain('--gist');
});
it('publish help names the registry, the token env var, and the opt-out behaviour', () => {
const result = runHelp('publish');
expect(result.status).toBe(0);
expect(result.stdout).toContain('--id <owner/repo>');
expect(result.stdout).toContain('--skip-git');
// Discoverability contract: a contributor scanning `--help` must see
// (a) which registry this dispatches to, and (b) the env var that
// gates the opt-in. Both are part of the no-token contract.
expect(result.stdout).toContain('understand-quickly');
expect(result.stdout).toContain('UNDERSTAND_QUICKLY_TOKEN');
});
it('analyze help includes the FTS repair option', () => {
const result = runHelp('analyze');
expect(result.status).toBe(0);
expect(result.stdout).toContain('--repair-fts');
});
it('localizes commander-generated option metadata labels', () => {
const english = metadataHelp('en');
const chinese = metadataHelp('zh-CN');
expect(english).toContain('choices: "fast", "safe"');
expect(english).toContain('default: "5"');
expect(english).toContain('preset: "auto"');
expect(english).toContain('env: GITNEXUS_TOKEN');
expect(chinese).toContain('可选值: "fast", "safe"');
expect(chinese).toContain('默认: "5"');
expect(chinese).toContain('预设: "auto"');
expect(chinese).toContain('环境变量: GITNEXUS_TOKEN');
});
});