Merge pull request #402 from adonisdoda/feat/index-cli

This commit is contained in:
Zander Raycraft 2026-03-25 20:22:21 -05:00 • committed by GitHub
commit 6fabd7a2df
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 354 additions and 0 deletions

View file

@ -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

View file

@ -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("");
};

View file

@ -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')

View file

@ -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");
});
});