From 680cefd00c90814383e4fa3c5b804d046f4d7cf1 Mon Sep 17 00:00:00 2001 From: adonis Date: Fri, 20 Mar 2026 19:36:07 -0300 Subject: [PATCH] feat(cli): refactor indexCommand for improved error handling and add unit tests --- gitnexus/src/cli/index-repo.ts | 57 ++++---- gitnexus/test/unit/index-repo-command.test.ts | 129 ++++++++++++++++++ 2 files changed, 159 insertions(+), 27 deletions(-) create mode 100644 gitnexus/test/unit/index-repo-command.test.ts diff --git a/gitnexus/src/cli/index-repo.ts b/gitnexus/src/cli/index-repo.ts index 8c1600f2b..26bb0eff6 100644 --- a/gitnexus/src/cli/index-repo.ts +++ b/gitnexus/src/cli/index-repo.ts @@ -9,17 +9,14 @@ * shared team index). */ -import path from 'path'; -import fs from 'fs/promises'; -import { execFileSync } from 'child_process'; +import path from "path"; +import fs from "fs/promises"; import { getStoragePaths, loadMeta, registerRepo, - addToGitignore, - getGlobalRegistryPath, -} from '../storage/repo-manager.js'; -import { getGitRoot, isGitRepo } from '../storage/git.js'; +} from "../storage/repo-manager.js"; +import { getGitRoot, isGitRepo } from "../storage/git.js"; export interface IndexOptions { force?: boolean; @@ -29,9 +26,11 @@ export const indexCommand = async ( inputPathParts?: string[], options?: IndexOptions, ) => { - console.log('\n GitNexus Index\n'); + console.log("\n GitNexus Index\n"); - const inputPath = inputPathParts?.length ? inputPathParts.join(' ') : undefined; + const inputPath = inputPathParts?.length + ? inputPathParts.join(" ") + : undefined; let repoPath: string; if (inputPath) { @@ -39,21 +38,28 @@ export const indexCommand = async ( } else { const gitRoot = getGitRoot(process.cwd()); if (!gitRoot) { - console.log(' Not inside a git repository, try to run git init\n'); + console.log(" Not inside a git repository, try to run git init\n"); process.exitCode = 1; return; } repoPath = gitRoot; } - const { storagePath, lbugPath, metaPath } = getStoragePaths(repoPath); + if (!isGitRepo(repoPath)) { + console.log(` Not a git repository: ${repoPath}`); + console.log(" Initialize one with `git init` or choose a valid repo path.\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'); + console.log(" Run `gitnexus analyze` to build the index first.\n"); process.exitCode = 1; return; } @@ -63,7 +69,7 @@ export const indexCommand = async ( 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'); + console.log(" Run `gitnexus analyze` to build the index.\n"); process.exitCode = 1; return; } @@ -74,8 +80,8 @@ export const indexCommand = async ( 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'); + 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; } @@ -83,14 +89,13 @@ export const indexCommand = async ( // --force: build a minimal meta so the repo can be registered meta = { repoPath, - lastCommit: '', + lastCommit: "", indexedAt: new Date().toISOString(), }; } // ── Register in global registry ─────────────────────────────────── await registerRepo(repoPath, meta); - await addToGitignore(repoPath); const projectName = path.basename(repoPath); const { stats } = meta; @@ -98,19 +103,17 @@ export const indexCommand = async ( 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.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(' | ')}`); + if (parts.length) console.log(` ${parts.join(" | ")}`); } console.log(` ${repoPath}`); - try { - await fs.access(getGlobalRegistryPath()); - } catch { - console.log('\n Tip: Run `gitnexus setup` to configure MCP for your editor.'); - } - - console.log(''); + console.log(""); }; 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..d8cba675a --- /dev/null +++ b/gitnexus/test/unit/index-repo-command.test.ts @@ -0,0 +1,129 @@ +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 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, +})); + +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); + 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(process.exitCode).toBeUndefined(); + }); +});