mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-12 23:02:45 +00:00
* 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>
423 lines
17 KiB
TypeScript
423 lines
17 KiB
TypeScript
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');
|
||
});
|
||
});
|