mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-11-12 08:41:34 +00:00
Merge pull request #402 from adonisdoda/feat/index-cli
This commit is contained in:
commit
6fabd7a2df
4 changed files with 354 additions and 0 deletions
|
|
@ -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
|
||||
|
|
|
|||
137
gitnexus/src/cli/index-repo.ts
Normal file
137
gitnexus/src/cli/index-repo.ts
Normal 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("");
|
||||
};
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
209
gitnexus/test/unit/index-repo-command.test.ts
Normal file
209
gitnexus/test/unit/index-repo-command.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue