diff --git a/gitnexus/README.md b/gitnexus/README.md index 43979d6ab..ac2990636 100644 --- a/gitnexus/README.md +++ b/gitnexus/README.md @@ -154,6 +154,7 @@ gitnexus analyze --embeddings # Enable embedding generation (slower, better gitnexus analyze --verbose # Log skipped files when parsers are unavailable gitnexus mcp # Start MCP server (stdio) — serves all indexed repos gitnexus serve # Start local HTTP server (multi-repo) for web UI +gitnexus index # Register an existing .gitnexus/ folder into the global registry gitnexus list # List all indexed repositories gitnexus status # Show index status for current repo gitnexus clean # Delete index for current repo diff --git a/gitnexus/src/cli/index-repo.ts b/gitnexus/src/cli/index-repo.ts new file mode 100644 index 000000000..bb1ce5a45 --- /dev/null +++ b/gitnexus/src/cli/index-repo.ts @@ -0,0 +1,137 @@ +/** + * Index Command + * + * Registers an existing .gitnexus/ folder into the global registry so the + * MCP server can discover the repo without running a full `gitnexus analyze`. + * + * Useful when a pre-built .gitnexus/ directory is already present (e.g. after + * cloning a repo that ships its index, restoring from backup, or using a + * shared team index). + */ + +import path from "path"; +import fs from "fs/promises"; +import { + getStoragePaths, + loadMeta, + addToGitignore, + registerRepo, +} from "../storage/repo-manager.js"; +import { getGitRoot, isGitRepo } from "../storage/git.js"; + +export interface IndexOptions { + force?: boolean; + allowNonGit?: boolean; +} + +export const indexCommand = async ( + inputPathParts?: string[], + options?: IndexOptions, +) => { + console.log("\n GitNexus Index\n"); + + const inputPath = inputPathParts?.length + ? inputPathParts.join(" ") + : undefined; + + if (inputPathParts && inputPathParts.length > 1) { + const resolvedCombinedPath = path.resolve(inputPath); + try { + await fs.access(resolvedCombinedPath); + } catch { + console.log(" The `index` command accepts a single path only."); + console.log(" If your path contains spaces, wrap it in quotes."); + console.log(` Received multiple path parts: ${inputPathParts.join(", ")}`); + console.log(""); + process.exitCode = 1; + return; + } + } + + let repoPath: string; + if (inputPath) { + repoPath = path.resolve(inputPath); + } else { + const gitRoot = getGitRoot(process.cwd()); + if (!gitRoot) { + console.log(" Not inside a git repository, try to run git init\n"); + process.exitCode = 1; + return; + } + repoPath = gitRoot; + } + + if (!options?.allowNonGit && !isGitRepo(repoPath)) { + console.log(` Not a git repository: ${repoPath}`); + console.log(" Initialize one with `git init` or choose a valid repo path.\n"); + console.log(" Or use --allow-non-git to register an existing .gitnexus index anyway.\n"); + process.exitCode = 1; + return; + } + + const { storagePath, lbugPath } = getStoragePaths(repoPath); + + // ── Verify .gitnexus/ exists ────────────────────────────────────── + try { + await fs.access(storagePath); + } catch { + console.log(` No .gitnexus/ folder found at: ${storagePath}`); + console.log(" Run `gitnexus analyze` to build the index first.\n"); + process.exitCode = 1; + return; + } + + // ── Verify lbug database exists ─────────────────────────────────── + try { + await fs.access(lbugPath); + } catch { + console.log(` .gitnexus/ folder exists but contains no LadybugDB index.`); + console.log(" Run `gitnexus analyze` to build the index.\n"); + process.exitCode = 1; + return; + } + + // ── Load or reconstruct meta ────────────────────────────────────── + let meta = await loadMeta(storagePath); + + if (!meta) { + if (!options?.force) { + console.log(` .gitnexus/ exists but meta.json is missing.`); + console.log(" Use --force to register anyway (stats will be empty),"); + console.log(" or run `gitnexus analyze` to rebuild properly.\n"); + process.exitCode = 1; + return; + } + + // --force: build a minimal meta so the repo can be registered + meta = { + repoPath, + lastCommit: "", + indexedAt: new Date().toISOString(), + }; + } + + // ── Register in global registry ─────────────────────────────────── + await registerRepo(repoPath, meta); + await addToGitignore(repoPath); + + const projectName = path.basename(repoPath); + const { stats } = meta; + + console.log(` Repository registered: ${projectName}`); + if (stats) { + const parts: string[] = []; + if (stats.nodes != null) { + parts.push(`${stats.nodes.toLocaleString()} nodes`); + } + if (stats.edges != null) { + parts.push(`${stats.edges.toLocaleString()} edges`); + } + if (stats.communities != null) parts.push(`${stats.communities} clusters`); + if (stats.processes != null) parts.push(`${stats.processes} flows`); + if (parts.length) console.log(` ${parts.join(" | ")}`); + } + console.log(` ${repoPath}`); + + console.log(""); +}; \ No newline at end of file diff --git a/gitnexus/src/cli/index.ts b/gitnexus/src/cli/index.ts index 5e65b0e78..595212ff9 100644 --- a/gitnexus/src/cli/index.ts +++ b/gitnexus/src/cli/index.ts @@ -33,6 +33,13 @@ program .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') diff --git a/gitnexus/test/unit/index-repo-command.test.ts b/gitnexus/test/unit/index-repo-command.test.ts new file mode 100644 index 000000000..f1ae6fba3 --- /dev/null +++ b/gitnexus/test/unit/index-repo-command.test.ts @@ -0,0 +1,209 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import path from "node:path"; + +const mockAccess = vi.fn(); +const mockGetStoragePaths = vi.fn(); +const mockLoadMeta = vi.fn(); +const mockRegisterRepo = vi.fn(); +const mockAddToGitignore = vi.fn(); +const mockGetGitRoot = vi.fn(); +const mockIsGitRepo = vi.fn(); + +vi.mock("fs/promises", () => ({ + default: { + access: mockAccess, + }, +})); + +vi.mock("../../src/storage/repo-manager.js", () => ({ + getStoragePaths: mockGetStoragePaths, + loadMeta: mockLoadMeta, + registerRepo: mockRegisterRepo, + addToGitignore: mockAddToGitignore, +})); + +vi.mock("../../src/storage/git.js", () => ({ + getGitRoot: mockGetGitRoot, + isGitRepo: mockIsGitRepo, +})); + +describe("indexCommand", () => { + const resolvedRepo = path.resolve("/repo"); + const resolvedOutside = path.resolve("/outside/path"); + + beforeEach(() => { + vi.clearAllMocks(); + vi.restoreAllMocks(); + process.exitCode = undefined; + + mockGetStoragePaths.mockImplementation((repoPath: string) => ({ + storagePath: `${repoPath}/.gitnexus`, + lbugPath: `${repoPath}/.gitnexus/lbug`, + metaPath: `${repoPath}/.gitnexus/meta.json`, + })); + mockLoadMeta.mockResolvedValue({ + repoPath: resolvedRepo, + lastCommit: "abc123", + indexedAt: "2026-03-20T00:00:00.000Z", + stats: { nodes: 10, edges: 20 }, + }); + mockAccess.mockResolvedValue(undefined); + mockAddToGitignore.mockResolvedValue(undefined); + mockGetGitRoot.mockReturnValue(resolvedRepo); + mockIsGitRepo.mockReturnValue(true); + }); + + it("fails when target path is not a git repository", async () => { + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + mockIsGitRepo.mockReturnValue(false); + + const { indexCommand } = await import("../../src/cli/index-repo.js"); + await indexCommand(["/outside/path"]); + + expect(mockRegisterRepo).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + expect(logSpy).toHaveBeenCalledWith( + ` Not a git repository: ${resolvedOutside}`, + ); + }); + + it("fails when .gitnexus folder does not exist", async () => { + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + mockAccess.mockRejectedValueOnce(new Error("missing .gitnexus")); + + const { indexCommand } = await import("../../src/cli/index-repo.js"); + await indexCommand(["/repo"]); + + expect(mockRegisterRepo).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + expect(logSpy).toHaveBeenCalledWith( + ` No .gitnexus/ folder found at: ${resolvedRepo}/.gitnexus`, + ); + }); + + it("fails when lbug database does not exist", async () => { + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + mockAccess + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error("missing lbug")); + + const { indexCommand } = await import("../../src/cli/index-repo.js"); + await indexCommand(["/repo"]); + + expect(mockRegisterRepo).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + expect(logSpy).toHaveBeenCalledWith( + " .gitnexus/ folder exists but contains no LadybugDB index.", + ); + }); + + it("fails when meta.json is missing and --force is not set", async () => { + mockLoadMeta.mockResolvedValue(null); + + const { indexCommand } = await import("../../src/cli/index-repo.js"); + await indexCommand(["/repo"]); + + expect(mockRegisterRepo).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + }); + + it("registers with minimal metadata when meta is missing and --force is set", async () => { + mockLoadMeta.mockResolvedValue(null); + + const { indexCommand } = await import("../../src/cli/index-repo.js"); + await indexCommand(["/repo"], { force: true }); + + expect(mockRegisterRepo).toHaveBeenCalledTimes(1); + expect(mockRegisterRepo).toHaveBeenCalledWith( + resolvedRepo, + expect.objectContaining({ + repoPath: resolvedRepo, + lastCommit: "", + }), + ); + expect(process.exitCode).toBeUndefined(); + }); + + it("registers successfully with existing metadata", async () => { + const { indexCommand } = await import("../../src/cli/index-repo.js"); + await indexCommand(["/repo"]); + + expect(mockRegisterRepo).toHaveBeenCalledTimes(1); + expect(mockRegisterRepo).toHaveBeenCalledWith( + resolvedRepo, + expect.objectContaining({ repoPath: resolvedRepo }), + ); + expect(mockAddToGitignore).toHaveBeenCalledTimes(1); + expect(mockAddToGitignore).toHaveBeenCalledWith(resolvedRepo); + expect(process.exitCode).toBeUndefined(); + }); + + it("registers non-git path when --allow-non-git is set", async () => { + mockIsGitRepo.mockReturnValue(false); + + const { indexCommand } = await import("../../src/cli/index-repo.js"); + await indexCommand(["/outside/path"], { allowNonGit: true }); + + expect(mockRegisterRepo).toHaveBeenCalledTimes(1); + expect(process.exitCode).toBeUndefined(); + }); + + it("fails when called with no path and cwd is not inside a git repo", async () => { + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + mockGetGitRoot.mockReturnValue(null); + + const { indexCommand } = await import("../../src/cli/index-repo.js"); + await indexCommand(); // no args + + expect(mockRegisterRepo).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + expect(logSpy).toHaveBeenCalledWith( + " Not inside a git repository, try to run git init\n", + ); + }); + + it("registers from cwd when no path is provided", async () => { + // getGitRoot already mocked to return resolvedRepo in beforeEach + const { indexCommand } = await import("../../src/cli/index-repo.js"); + await indexCommand(); // no args + + expect(mockRegisterRepo).toHaveBeenCalledWith( + resolvedRepo, + expect.objectContaining({ repoPath: resolvedRepo }), + ); + expect(mockAddToGitignore).toHaveBeenCalledWith(resolvedRepo); + expect(process.exitCode).toBeUndefined(); + }); + + it("fails when multiple path parts do not resolve to a single existing path", async () => { + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const ambiguousPath = path.resolve("/repo /other"); + + mockAccess.mockImplementation(async (targetPath: string) => { + if (targetPath === ambiguousPath) { + throw new Error("missing combined path"); + } + return undefined; + }); + + const { indexCommand } = await import("../../src/cli/index-repo.js"); + await indexCommand(["/repo", "/other"]); + + expect(mockRegisterRepo).not.toHaveBeenCalled(); + expect(mockAddToGitignore).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + expect(logSpy).toHaveBeenCalledWith( + " The `index` command accepts a single path only.", + ); + }); + + it("prints node and edge stats after registration", async () => { + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + const { indexCommand } = await import("../../src/cli/index-repo.js"); + await indexCommand(["/repo"]); + + expect(logSpy).toHaveBeenCalledWith(" Repository registered: repo"); + expect(logSpy).toHaveBeenCalledWith(" 10 nodes | 20 edges"); + }); +});