mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-30 01:51:20 +00:00
* feat(cli): gitnexus remove <target> to unindex a registered repo by name or path (#664)
Add a `remove` CLI command that deletes the `.gitnexus/` index AND
unregisters a repo from the global registry (~/.gitnexus/registry.json),
addressing the lifecycle gap flagged in #664: previously users had to
cd into the repo to run `clean`, and there was no path-based or
alias-based remove for an already-deleted working tree.
- New command `gitnexus remove <target> [-f|--force]`. `<target>` is
alias / basename-derived name / remote-inferred name / absolute path.
- New helper `resolveRegistryEntry(entries, target)` in repo-manager.ts
with path > name precedence; throws RegistryNotFoundError or
RegistryAmbiguousTargetError (typed, `kind`-discriminated).
- Atomicity mirrors `clean`: fs.rm first, then unregisterRepo; partial
failures self-heal on next `listRegisteredRepos({ validate: true })`.
- Idempotent on unknown targets (exit 0 with warning) per the #664
spec: "behave atomically and idempotently so retries are safe".
- `--force` uses `clean`-style confirmation-skip semantics — distinct
from `analyze --force` (pipeline re-index); here there is no pipeline
so no conflation.
- 7 new unit tests cover resolver precedence, case sensitivity,
ambiguity, and not-found hints; 2 integration tests cover the real
CLI -> registry -> filesystem chain including the --allow-duplicate-name
(#829) ambiguity case.
* fix(cli): canonicalize repo paths so remove/register match across platforms (#1003 review)
Address review feedback from @evander-wang and @magyargergo on PR #1003
plus the Windows + macOS CI failure (same root cause).
Problem:
- macOS: /var is a symlink to /private/var. `path.resolve` does NOT
follow symlinks, so a child running analyze in /var/folders/X stores
/private/var/folders/X (realpath from OS cwd) but an outer caller
passing the symlink form misses.
- Windows: GitHub runners surface tmpdirs in 8.3 short-name form
(RUNNERA~1) while process.cwd() returns the long form (runneradmin).
Same divergence.
Fix: new `canonicalizePath(p)` helper wraps `path.resolve` plus
`fs.realpathSync.native`, falling back to `path.resolve` when the path
doesn't exist (preserves idempotent-on-missing semantics needed by
`remove <unknown>`). Applied at 3 call-sites — registerRepo,
unregisterRepo, resolveRegistryEntry — canonicalising BOTH the input
and each stored `entry.path` at compare time. That last bit is the
backward-compat story: registries written by older versions
(pre-canonicalisation) still match correctly, so we don't need a
migration script.
Test side: the ambiguous-target integration test now reads the path
from the registry snapshot rather than passing the outer `repoA`
variable directly, so it exercises the registry contract regardless of
which path form the platform stores. 4 new unit tests cover the helper
(idempotent, fallback-on-missing, absolute-for-relative) plus the
backward-compat resolver path.
* fix(cli): store resolved (non-canonical) path, compare via canonicalizePath (#1003 CI)
Follow-up to c5eceba0. The previous commit canonicalised the repo path
at BOTH write-time AND compare-time in registerRepo — that expanded
Windows 8.3 short names (RUNNER~1) to long names (runneradmin) when
storing `entry.path`. Pre-existing #829 unit tests that assert
`path.resolve(err.existingPath) === path.resolve(tmpPath)` then broke
because `tmpPath` is still short-form (path.resolve doesn't expand
8.3) while `entry.path` was long-form (canonicalizePath does).
Fix: split storage from comparison.
- entry.path stores `path.resolve(repoPath)` — whatever form the
caller passed. `list` output and error messages show the path the
user typed.
- All compare points (existing-entry lookup in registerRepo, the
collision guard, unregisterRepo, resolveRegistryEntry path tier)
canonicalise BOTH sides via `canonicalizePath`. That is where the
/var ↔ /private/var and RUNNER~1 ↔ runneradmin divergence actually
matters.
Net effect: storage is tolerant (preserves user input), matching is
strict (canonical-vs-canonical). Pre-existing #829 tests stay green
because `err.existingPath` is unchanged from what `path.resolve` gives
back; the cross-platform CI failure from #1003 stays fixed because
every comparison path goes through `canonicalizePath`.
* fix(cli): refuse destructive fs.rm when registry storagePath isn't <repo>/.gitnexus (#1003 review)
Address @magyargergo's inline review finding on remove.ts:89 and the
sibling vulnerability in clean.ts --all (caught during a pre-commit
safety audit). ~/.gitnexus/registry.json is a user-writable plain-text
file, so a corrupted or hand-edited entry could point storagePath at
the repo root (catastrophic: rm the working tree), an empty string
(→ cwd), a parent dir, or anywhere else. fs.rm(recursive: true,
force: true) on any of those is a runtime disaster.
- New UnsafeStoragePathError + exported assertSafeStoragePath() in
repo-manager.ts. Pure lexical string check (Windows-case-
insensitive) asserting entry.storagePath === path.join(entry.path,
'.gitnexus').
- Guard wired into BOTH destructive registry-trusting sites:
- remove.ts: exit 1 with actionable hint
- clean.ts --all: skip the poisoned entry with a warning and
continue (preserves existing per-repo error tolerance — one bad
entry doesn't halt the batch)
- clean.ts default path and server/api.ts are safe-by-construction
(they recompute storagePath from findRepo / getStoragePath rather
than trusting the registry field).
- 8 unit tests cover the guard (valid, repo-root, parent, empty,
unrelated, sibling, error payload, Windows case).
- 2 integration tests prove the full CLI path: remove-poisoned exits
1 without touching the working tree; clean --all with a poisoned
sibling entry cleans the good entry, skips the bad one, and leaves
the poisoned repo intact.
* test(cli): assert full remove dry-run + success output shape (#1003 NIT)
Address the one NIT from the senior-reviewer pass on PR #1003: the
integration test was only checking for the "Run with --force" hint in
dry-run output, not verifying that the three actual console.log lines
(alias, repo path, storage path) appear. Same weak check on the
success-branch "Removed" output.
Tighten both assertions to toContain(alias), toContain(entry.path),
toContain(storagePath). Catches silent format regressions — e.g. a
future refactor that drops a console.log line or swaps
entry.name/entry.path in the output.
No code change; +20 test lines. All assertions in the happy-path
integration test now fire for a meaningful reason.
183 lines
8.2 KiB
JavaScript
183 lines
8.2 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
// Heap re-spawn removed — only analyze.ts needs the 8GB heap (via its own ensureHeap()).
|
|
// Removing it from here improves MCP server startup time significantly.
|
|
|
|
import { Command } from 'commander';
|
|
import { createRequire } from 'node:module';
|
|
import { createLazyAction } from './lazy-action.js';
|
|
import { registerGroupCommands } from './group.js';
|
|
|
|
const _require = createRequire(import.meta.url);
|
|
const pkg = _require('../../package.json');
|
|
const program = new Command();
|
|
|
|
program.name('gitnexus').description('GitNexus local CLI and MCP server').version(pkg.version);
|
|
|
|
program
|
|
.command('setup')
|
|
.description('One-time setup: configure MCP for Cursor, Claude Code, OpenCode, Codex')
|
|
.action(createLazyAction(() => import('./setup.js'), 'setupCommand'));
|
|
|
|
program
|
|
.command('analyze [path]')
|
|
.description('Index a repository (full analysis)')
|
|
.option('-f, --force', 'Force full re-index even if up to date')
|
|
.option('--embeddings', 'Enable embedding generation for semantic search (off by default)')
|
|
.option('--skills', 'Generate repo-specific skill files from detected communities')
|
|
.option('--skip-agents-md', 'Skip updating the gitnexus section in AGENTS.md and CLAUDE.md')
|
|
.option('--no-stats', 'Omit volatile file/symbol counts from AGENTS.md and CLAUDE.md')
|
|
.option('--skip-git', 'Index a folder without requiring a .git directory')
|
|
.option(
|
|
'--name <alias>',
|
|
'Register this repo under a custom name in ~/.gitnexus/registry.json ' +
|
|
'(disambiguates repos whose paths share a basename, e.g. two different .../app folders)',
|
|
)
|
|
.option(
|
|
'--allow-duplicate-name',
|
|
'Register this repo even if another path already uses the same --name alias. ' +
|
|
'Leaves `-r <name>` ambiguous for the two paths; use -r <path> to disambiguate.',
|
|
)
|
|
.option('-v, --verbose', 'Enable verbose ingestion warnings (default: false)')
|
|
.addHelpText(
|
|
'after',
|
|
'\nEnvironment variables:\n GITNEXUS_NO_GITIGNORE=1 Skip .gitignore parsing (still reads .gitnexusignore)',
|
|
)
|
|
.action(createLazyAction(() => import('./analyze.js'), 'analyzeCommand'));
|
|
|
|
program
|
|
.command('index [path...]')
|
|
.description(
|
|
'Register an existing .gitnexus/ folder into the global registry (no re-analysis needed)',
|
|
)
|
|
.option('-f, --force', 'Register even if meta.json is missing (stats will be empty)')
|
|
.option('--allow-non-git', 'Allow registering folders that are not Git repositories')
|
|
.action(createLazyAction(() => import('./index-repo.js'), 'indexCommand'));
|
|
|
|
program
|
|
.command('serve')
|
|
.description('Start local HTTP server for web UI connection')
|
|
.option('-p, --port <port>', 'Port number', '4747')
|
|
.option('--host <host>', 'Bind address (default: 127.0.0.1, use 0.0.0.0 for remote access)')
|
|
.action(createLazyAction(() => import('./serve.js'), 'serveCommand'));
|
|
|
|
program
|
|
.command('mcp')
|
|
.description('Start MCP server (stdio) — serves all indexed repos')
|
|
.action(createLazyAction(() => import('./mcp.js'), 'mcpCommand'));
|
|
|
|
program
|
|
.command('list')
|
|
.description('List all indexed repositories')
|
|
.action(createLazyAction(() => import('./list.js'), 'listCommand'));
|
|
|
|
program
|
|
.command('status')
|
|
.description('Show index status for current repo')
|
|
.action(createLazyAction(() => import('./status.js'), 'statusCommand'));
|
|
|
|
program
|
|
.command('clean')
|
|
.description('Delete GitNexus index for current repo')
|
|
.option('-f, --force', 'Skip confirmation prompt')
|
|
.option('--all', 'Clean all indexed repos')
|
|
.action(createLazyAction(() => import('./clean.js'), 'cleanCommand'));
|
|
|
|
program
|
|
.command('remove <target>')
|
|
.description(
|
|
'Delete the GitNexus index for a registered repo (by alias, name, or absolute path). ' +
|
|
'Unlike `clean`, does not require being inside the repo. Idempotent on unknown targets.',
|
|
)
|
|
.option('-f, --force', 'Skip confirmation prompt')
|
|
.action(createLazyAction(() => import('./remove.js'), 'removeCommand'));
|
|
|
|
program
|
|
.command('wiki [path]')
|
|
.description('Generate repository wiki from knowledge graph')
|
|
.option('-f, --force', 'Force full regeneration even if up to date')
|
|
.option('--provider <provider>', 'LLM provider: openai or cursor (default: openai)')
|
|
.option('--model <model>', 'LLM model or Azure deployment name (default: minimax/minimax-m2.5)')
|
|
.option(
|
|
'--base-url <url>',
|
|
'LLM API base URL. Azure v1: https://{resource}.openai.azure.com/openai/v1',
|
|
)
|
|
.option('--api-key <key>', 'LLM API key or Azure api-key (saved to ~/.gitnexus/config.json)')
|
|
.option(
|
|
'--api-version <version>',
|
|
'Azure api-version query param, e.g. 2024-10-21 (legacy Azure API only)',
|
|
)
|
|
.option(
|
|
'--reasoning-model',
|
|
'Mark deployment as reasoning model (o1/o3/o4-mini) — strips temperature, uses max_completion_tokens',
|
|
)
|
|
.option('--no-reasoning-model', 'Disable reasoning model mode (overrides saved config)')
|
|
.option('--concurrency <n>', 'Parallel LLM calls (default: 3)', '3')
|
|
.option('--gist', 'Publish wiki as a public GitHub Gist after generation')
|
|
.option('-v, --verbose', 'Enable verbose output (show LLM commands and responses)')
|
|
.option('--review', 'Stop after grouping to review module structure before generating pages')
|
|
.action(createLazyAction(() => import('./wiki.js'), 'wikiCommand'));
|
|
|
|
program
|
|
.command('augment <pattern>')
|
|
.description('Augment a search pattern with knowledge graph context (used by hooks)')
|
|
.action(createLazyAction(() => import('./augment.js'), 'augmentCommand'));
|
|
|
|
// ─── Direct Tool Commands (no MCP overhead) ────────────────────────
|
|
// These invoke LocalBackend directly for use in eval, scripts, and CI.
|
|
|
|
program
|
|
.command('query <search_query>')
|
|
.description('Search the knowledge graph for execution flows related to a concept')
|
|
.option('-r, --repo <name>', 'Target repository (omit if only one indexed)')
|
|
.option('-c, --context <text>', 'Task context to improve ranking')
|
|
.option('-g, --goal <text>', 'What you want to find')
|
|
.option('-l, --limit <n>', 'Max processes to return (default: 5)')
|
|
.option('--content', 'Include full symbol source code')
|
|
.action(createLazyAction(() => import('./tool.js'), 'queryCommand'));
|
|
|
|
program
|
|
.command('context [name]')
|
|
.description('360-degree view of a code symbol: callers, callees, processes')
|
|
.option('-r, --repo <name>', 'Target repository')
|
|
.option('-u, --uid <uid>', 'Direct symbol UID (zero-ambiguity lookup)')
|
|
.option('-f, --file <path>', 'File path to disambiguate common names')
|
|
.option('--content', 'Include full symbol source code')
|
|
.action(createLazyAction(() => import('./tool.js'), 'contextCommand'));
|
|
|
|
program
|
|
.command('impact <target>')
|
|
.description('Blast radius analysis: what breaks if you change a symbol')
|
|
.option('-d, --direction <dir>', 'upstream (dependants) or downstream (dependencies)', 'upstream')
|
|
.option('-r, --repo <name>', 'Target repository')
|
|
.option('--depth <n>', 'Max relationship depth (default: 3)')
|
|
.option('--include-tests', 'Include test files in results')
|
|
.action(createLazyAction(() => import('./tool.js'), 'impactCommand'));
|
|
|
|
program
|
|
.command('cypher <query>')
|
|
.description('Execute raw Cypher query against the knowledge graph')
|
|
.option('-r, --repo <name>', 'Target repository')
|
|
.action(createLazyAction(() => import('./tool.js'), 'cypherCommand'));
|
|
|
|
program
|
|
.command('detect-changes')
|
|
.alias('detect_changes')
|
|
.description('Map git diff hunks to indexed symbols and affected execution flows')
|
|
.option('-s, --scope <scope>', 'What to analyze: unstaged, staged, all, or compare', 'unstaged')
|
|
.option('-b, --base-ref <ref>', 'Branch/commit for compare scope (e.g. main)')
|
|
.option('-r, --repo <name>', 'Target repository')
|
|
.action(createLazyAction(() => import('./tool.js'), 'detectChangesCommand'));
|
|
|
|
// ─── Eval Server (persistent daemon for SWE-bench) ─────────────────
|
|
|
|
program
|
|
.command('eval-server')
|
|
.description('Start lightweight HTTP server for fast tool calls during evaluation')
|
|
.option('-p, --port <port>', 'Port number', '4848')
|
|
.option('--idle-timeout <seconds>', 'Auto-shutdown after N seconds idle (0 = disabled)', '0')
|
|
.action(createLazyAction(() => import('./eval-server.js'), 'evalServerCommand'));
|
|
|
|
registerGroupCommands(program);
|
|
|
|
program.parse(process.argv);
|