From c1996f45d87ecadd1fb53774c6666d450eab206c Mon Sep 17 00:00:00 2001 From: adonis Date: Fri, 20 Mar 2026 19:16:09 -0300 Subject: [PATCH 1/5] feat(cli): add 'index' command to register existing .gitnexus/ folder --- gitnexus/README.md | 1 + gitnexus/src/cli/index-repo.ts | 116 +++++++++++++++++++++++++++++++++ gitnexus/src/cli/index.ts | 6 ++ 3 files changed, 123 insertions(+) create mode 100644 gitnexus/src/cli/index-repo.ts diff --git a/gitnexus/README.md b/gitnexus/README.md index e4fe5b623..2bf6be44e 100644 --- a/gitnexus/README.md +++ b/gitnexus/README.md @@ -150,6 +150,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..8c1600f2b --- /dev/null +++ b/gitnexus/src/cli/index-repo.ts @@ -0,0 +1,116 @@ +/** + * 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 { execFileSync } from 'child_process'; +import { + getStoragePaths, + loadMeta, + registerRepo, + addToGitignore, + getGlobalRegistryPath, +} from '../storage/repo-manager.js'; +import { getGitRoot, isGitRepo } from '../storage/git.js'; + +export interface IndexOptions { + force?: boolean; +} + +export const indexCommand = async ( + inputPathParts?: string[], + options?: IndexOptions, +) => { + console.log('\n GitNexus Index\n'); + + const inputPath = inputPathParts?.length ? inputPathParts.join(' ') : undefined; + + 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; + } + + const { storagePath, lbugPath, metaPath } = 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}`); + + try { + await fs.access(getGlobalRegistryPath()); + } catch { + console.log('\n Tip: Run `gitnexus setup` to configure MCP for your editor.'); + } + + console.log(''); +}; diff --git a/gitnexus/src/cli/index.ts b/gitnexus/src/cli/index.ts index 6f5164f5c..f798b7756 100644 --- a/gitnexus/src/cli/index.ts +++ b/gitnexus/src/cli/index.ts @@ -32,6 +32,12 @@ 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)') + .action(createLazyAction(() => import('./index-repo.js'), 'indexCommand')); + program .command('serve') .description('Start local HTTP server for web UI connection') From 680cefd00c90814383e4fa3c5b804d046f4d7cf1 Mon Sep 17 00:00:00 2001 From: adonis Date: Fri, 20 Mar 2026 19:36:07 -0300 Subject: [PATCH 2/5] 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(); + }); +}); From 3567ef57947df4ec73e17f2d7f3971e213b83b32 Mon Sep 17 00:00:00 2001 From: adonis Date: Fri, 20 Mar 2026 19:46:47 -0300 Subject: [PATCH 3/5] feat(cli): add --allow-non-git option to index command and update tests --- gitnexus/src/cli/index-repo.ts | 6 ++++-- gitnexus/src/cli/index.ts | 1 + gitnexus/test/unit/index-repo-command.test.ts | 10 ++++++++++ 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/gitnexus/src/cli/index-repo.ts b/gitnexus/src/cli/index-repo.ts index 26bb0eff6..e316a036b 100644 --- a/gitnexus/src/cli/index-repo.ts +++ b/gitnexus/src/cli/index-repo.ts @@ -20,6 +20,7 @@ import { getGitRoot, isGitRepo } from "../storage/git.js"; export interface IndexOptions { force?: boolean; + allowNonGit?: boolean; } export const indexCommand = async ( @@ -45,9 +46,10 @@ export const indexCommand = async ( repoPath = gitRoot; } - if (!isGitRepo(repoPath)) { + 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; } @@ -116,4 +118,4 @@ export const indexCommand = async ( 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 f798b7756..ca9443d73 100644 --- a/gitnexus/src/cli/index.ts +++ b/gitnexus/src/cli/index.ts @@ -36,6 +36,7 @@ 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 diff --git a/gitnexus/test/unit/index-repo-command.test.ts b/gitnexus/test/unit/index-repo-command.test.ts index d8cba675a..63f502006 100644 --- a/gitnexus/test/unit/index-repo-command.test.ts +++ b/gitnexus/test/unit/index-repo-command.test.ts @@ -126,4 +126,14 @@ describe('indexCommand', () => { ); 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(); + }); }); From 6fe450d206fb50b843b1d560186945897ee327aa Mon Sep 17 00:00:00 2001 From: adonis Date: Sun, 22 Mar 2026 22:05:22 -0300 Subject: [PATCH 4/5] test(cli): refactor indexCommand tests for consistency and add new cases --- gitnexus/test/unit/index-repo-command.test.ts | 122 ++++++++++++------ 1 file changed, 82 insertions(+), 40 deletions(-) diff --git a/gitnexus/test/unit/index-repo-command.test.ts b/gitnexus/test/unit/index-repo-command.test.ts index 63f502006..176ffbcde 100644 --- a/gitnexus/test/unit/index-repo-command.test.ts +++ b/gitnexus/test/unit/index-repo-command.test.ts @@ -1,5 +1,5 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import path from 'node:path'; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import path from "node:path"; const mockAccess = vi.fn(); const mockGetStoragePaths = vi.fn(); @@ -8,26 +8,26 @@ const mockRegisterRepo = vi.fn(); const mockGetGitRoot = vi.fn(); const mockIsGitRepo = vi.fn(); -vi.mock('fs/promises', () => ({ +vi.mock("fs/promises", () => ({ default: { access: mockAccess, }, })); -vi.mock('../../src/storage/repo-manager.js', () => ({ +vi.mock("../../src/storage/repo-manager.js", () => ({ getStoragePaths: mockGetStoragePaths, loadMeta: mockLoadMeta, registerRepo: mockRegisterRepo, })); -vi.mock('../../src/storage/git.js', () => ({ +vi.mock("../../src/storage/git.js", () => ({ getGitRoot: mockGetGitRoot, isGitRepo: mockIsGitRepo, })); -describe('indexCommand', () => { - const resolvedRepo = path.resolve('/repo'); - const resolvedOutside = path.resolve('/outside/path'); +describe("indexCommand", () => { + const resolvedRepo = path.resolve("/repo"); + const resolvedOutside = path.resolve("/outside/path"); beforeEach(() => { vi.clearAllMocks(); @@ -41,8 +41,8 @@ describe('indexCommand', () => { })); mockLoadMeta.mockResolvedValue({ repoPath: resolvedRepo, - lastCommit: 'abc123', - indexedAt: '2026-03-20T00:00:00.000Z', + lastCommit: "abc123", + indexedAt: "2026-03-20T00:00:00.000Z", stats: { nodes: 10, edges: 20 }, }); mockAccess.mockResolvedValue(undefined); @@ -50,74 +50,80 @@ describe('indexCommand', () => { mockIsGitRepo.mockReturnValue(true); }); - it('fails when target path is not a git repository', async () => { - const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + 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']); + 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}`); + 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')); + 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']); + 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`); + 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(() => {}); + it("fails when lbug database does not exist", async () => { + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); mockAccess .mockResolvedValueOnce(undefined) - .mockRejectedValueOnce(new Error('missing lbug')); + .mockRejectedValueOnce(new Error("missing lbug")); - const { indexCommand } = await import('../../src/cli/index-repo.js'); - await indexCommand(['/repo']); + 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.'); + expect(logSpy).toHaveBeenCalledWith( + " .gitnexus/ folder exists but contains no LadybugDB index.", + ); }); - it('fails when meta.json is missing and --force is not set', async () => { + 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']); + 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 () => { + 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 }); + 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: '', + lastCommit: "", }), ); expect(process.exitCode).toBeUndefined(); }); - it('registers successfully with existing metadata', async () => { - const { indexCommand } = await import('../../src/cli/index-repo.js'); - await indexCommand(['/repo']); + 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( @@ -127,13 +133,49 @@ describe('indexCommand', () => { expect(process.exitCode).toBeUndefined(); }); - it('registers non-git path when --allow-non-git is set', async () => { + 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 }); + 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(process.exitCode).toBeUndefined(); + }); + + 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"); + }); }); From 1bb80c3858c0a84f5f84c4496dafa1748716af2f Mon Sep 17 00:00:00 2001 From: adonis Date: Mon, 23 Mar 2026 00:19:26 -0300 Subject: [PATCH 5/5] feat(cli): enhance index command with single path validation and add to .gitignore --- gitnexus/src/cli/index-repo.ts | 16 +++++++++++ gitnexus/test/unit/index-repo-command.test.ts | 28 +++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/gitnexus/src/cli/index-repo.ts b/gitnexus/src/cli/index-repo.ts index e316a036b..bb1ce5a45 100644 --- a/gitnexus/src/cli/index-repo.ts +++ b/gitnexus/src/cli/index-repo.ts @@ -14,6 +14,7 @@ import fs from "fs/promises"; import { getStoragePaths, loadMeta, + addToGitignore, registerRepo, } from "../storage/repo-manager.js"; import { getGitRoot, isGitRepo } from "../storage/git.js"; @@ -33,6 +34,20 @@ export const indexCommand = async ( ? 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); @@ -98,6 +113,7 @@ export const indexCommand = async ( // ── Register in global registry ─────────────────────────────────── await registerRepo(repoPath, meta); + await addToGitignore(repoPath); const projectName = path.basename(repoPath); const { stats } = meta; diff --git a/gitnexus/test/unit/index-repo-command.test.ts b/gitnexus/test/unit/index-repo-command.test.ts index 176ffbcde..f1ae6fba3 100644 --- a/gitnexus/test/unit/index-repo-command.test.ts +++ b/gitnexus/test/unit/index-repo-command.test.ts @@ -5,6 +5,7 @@ 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(); @@ -18,6 +19,7 @@ vi.mock("../../src/storage/repo-manager.js", () => ({ getStoragePaths: mockGetStoragePaths, loadMeta: mockLoadMeta, registerRepo: mockRegisterRepo, + addToGitignore: mockAddToGitignore, })); vi.mock("../../src/storage/git.js", () => ({ @@ -46,6 +48,7 @@ describe("indexCommand", () => { stats: { nodes: 10, edges: 20 }, }); mockAccess.mockResolvedValue(undefined); + mockAddToGitignore.mockResolvedValue(undefined); mockGetGitRoot.mockReturnValue(resolvedRepo); mockIsGitRepo.mockReturnValue(true); }); @@ -130,6 +133,8 @@ describe("indexCommand", () => { resolvedRepo, expect.objectContaining({ repoPath: resolvedRepo }), ); + expect(mockAddToGitignore).toHaveBeenCalledTimes(1); + expect(mockAddToGitignore).toHaveBeenCalledWith(resolvedRepo); expect(process.exitCode).toBeUndefined(); }); @@ -166,9 +171,32 @@ describe("indexCommand", () => { 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(() => {});