From cc95c668f57d6db82dd08f530b4291d4a2034c49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Fri, 1 May 2026 16:46:05 +0100 Subject: [PATCH] fix(cli): keep GitNexus ignores inside .gitnexus (#1248) * fix(cli): keep GitNexus ignores inside .gitnexus Avoid mutating analyzed repositories' root .gitignore while keeping generated GitNexus state untracked via .gitnexus/.gitignore. Made-with: Cursor * fix(cli): also use git info exclude for GitNexus storage When an analyzed repo has a real .git directory, add .gitnexus/ to .git/info/exclude so local Git metadata ignores generated storage without touching root .gitignore. Made-with: Cursor * fix(cli): keep skip-git subdir indexes ignored Ensure full analyze always writes the internal GitNexus ignore file so parent Git repositories stay clean for --skip-git subdirectory indexes. Made-with: Cursor --- gitnexus/src/cli/index-repo.ts | 4 +- gitnexus/src/core/run-analyze.ts | 9 +-- gitnexus/src/storage/repo-manager.ts | 47 ++++++++--- gitnexus/test/integration/cli-e2e.test.ts | 2 + gitnexus/test/unit/index-repo-command.test.ts | 14 ++-- gitnexus/test/unit/repo-manager.test.ts | 79 +++++++++++++++++++ gitnexus/test/unit/run-analyze.test.ts | 43 ++++++++++ gitnexus/test/unit/skip-git-cli.test.ts | 39 ++++++++- 8 files changed, 209 insertions(+), 28 deletions(-) diff --git a/gitnexus/src/cli/index-repo.ts b/gitnexus/src/cli/index-repo.ts index b909a40b5..52e8eb60d 100644 --- a/gitnexus/src/cli/index-repo.ts +++ b/gitnexus/src/cli/index-repo.ts @@ -14,7 +14,7 @@ import fs from 'fs/promises'; import { getStoragePaths, loadMeta, - addToGitignore, + ensureGitNexusIgnored, registerRepo, } from '../storage/repo-manager.js'; import { getGitRoot, getRemoteUrl, isGitRepo } from '../storage/git.js'; @@ -115,7 +115,7 @@ export const indexCommand = async (inputPathParts?: string[], options?: IndexOpt meta.remoteUrl = getRemoteUrl(repoPath); } await registerRepo(repoPath, meta); - await addToGitignore(repoPath); + await ensureGitNexusIgnored(repoPath); const projectName = path.basename(repoPath); const { stats } = meta; diff --git a/gitnexus/src/core/run-analyze.ts b/gitnexus/src/core/run-analyze.ts index 854c65e24..e14f7a30f 100644 --- a/gitnexus/src/core/run-analyze.ts +++ b/gitnexus/src/core/run-analyze.ts @@ -26,7 +26,7 @@ import { getStoragePaths, saveMeta, loadMeta, - addToGitignore, + ensureGitNexusIgnored, registerRepo, cleanupOldKuzuFiles, } from '../storage/repo-manager.js'; @@ -166,6 +166,7 @@ export async function runFullAnalysis( if (existingMeta && !options.force && existingMeta.lastCommit === currentCommit) { // Non-git folders have currentCommit = '' — always rebuild since we can't detect changes if (currentCommit !== '') { + await ensureGitNexusIgnored(repoPath); return { repoName: options.registryName ?? getInferredRepoName(repoPath) ?? path.basename(repoPath), repoPath, @@ -447,10 +448,8 @@ export async function runFullAnalysis( allowDuplicateName: options.allowDuplicateName, }); - // Only attempt to update .gitignore when a .git directory is present. - if (hasGitDir(repoPath)) { - await addToGitignore(repoPath); - } + // Keep generated .gitnexus contents ignored without editing the user's root .gitignore. + await ensureGitNexusIgnored(repoPath); // ── Generate AI context files (best-effort) ─────────────────────── let aggregatedClusterCount = 0; diff --git a/gitnexus/src/storage/repo-manager.ts b/gitnexus/src/storage/repo-manager.ts index f0a322180..9741f54b4 100644 --- a/gitnexus/src/storage/repo-manager.ts +++ b/gitnexus/src/storage/repo-manager.ts @@ -96,6 +96,7 @@ export interface RegistryEntry { } const GITNEXUS_DIR = '.gitnexus'; +const GITNEXUS_EXCLUDE_ENTRY = `${GITNEXUS_DIR}/`; // ─── Local Storage Helpers ───────────────────────────────────────────── @@ -238,23 +239,45 @@ export const findRepo = async (startPath: string): Promise = }; /** - * Add .gitnexus to .gitignore if not already present + * Keep generated index files ignored without modifying the user's root .gitignore. */ -export const addToGitignore = async (repoPath: string): Promise => { - const gitignorePath = path.join(repoPath, '.gitignore'); +export const ensureGitNexusIgnored = async (repoPath: string): Promise => { + const gitignorePath = path.join(getStoragePath(repoPath), '.gitignore'); + + await fs.mkdir(path.dirname(gitignorePath), { recursive: true }); + await fs.writeFile(gitignorePath, '*\n', 'utf-8'); + + await ensureGitInfoExclude(repoPath); +}; + +const ensureGitInfoExclude = async (repoPath: string): Promise => { + const gitDirPath = path.join(path.resolve(repoPath), '.git'); + const excludePath = path.join(gitDirPath, 'info', 'exclude'); try { - const content = await fs.readFile(gitignorePath, 'utf-8'); - if (content.includes(GITNEXUS_DIR)) return; - - const newContent = content.endsWith('\n') - ? `${content}${GITNEXUS_DIR}\n` - : `${content}\n${GITNEXUS_DIR}\n`; - await fs.writeFile(gitignorePath, newContent, 'utf-8'); + const gitDir = await fs.stat(gitDirPath); + if (!gitDir.isDirectory()) return; } catch { - // .gitignore doesn't exist, create it - await fs.writeFile(gitignorePath, `${GITNEXUS_DIR}\n`, 'utf-8'); + return; } + + await fs.mkdir(path.dirname(excludePath), { recursive: true }); + + let content = ''; + try { + content = await fs.readFile(excludePath, 'utf-8'); + } catch (err: any) { + if (err?.code !== 'ENOENT') throw err; + } + + const excludes = content + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line && !line.startsWith('#')); + if (excludes.includes(GITNEXUS_DIR) || excludes.includes(GITNEXUS_EXCLUDE_ENTRY)) return; + + const separator = content.length === 0 || content.endsWith('\n') ? '' : '\n'; + await fs.writeFile(excludePath, `${content}${separator}${GITNEXUS_EXCLUDE_ENTRY}\n`, 'utf-8'); }; // ─── Global Registry (~/.gitnexus/registry.json) ─────────────────────── diff --git a/gitnexus/test/integration/cli-e2e.test.ts b/gitnexus/test/integration/cli-e2e.test.ts index ecf1201ff..8037511bf 100644 --- a/gitnexus/test/integration/cli-e2e.test.ts +++ b/gitnexus/test/integration/cli-e2e.test.ts @@ -198,6 +198,8 @@ describe('CLI end-to-end', () => { const gitnexusDir = path.join(MINI_REPO, '.gitnexus'); expect(fs.existsSync(gitnexusDir)).toBe(true); expect(fs.statSync(gitnexusDir).isDirectory()).toBe(true); + expect(fs.existsSync(path.join(MINI_REPO, '.gitignore'))).toBe(false); + expect(fs.readFileSync(path.join(gitnexusDir, '.gitignore'), 'utf-8')).toBe('*\n'); }, 60_000); // Regression guard for issue #1169 — analyze must produce BOTH a diff --git a/gitnexus/test/unit/index-repo-command.test.ts b/gitnexus/test/unit/index-repo-command.test.ts index 3f7a57153..8e1994063 100644 --- a/gitnexus/test/unit/index-repo-command.test.ts +++ b/gitnexus/test/unit/index-repo-command.test.ts @@ -5,7 +5,7 @@ const mockAccess = vi.fn(); const mockGetStoragePaths = vi.fn(); const mockLoadMeta = vi.fn(); const mockRegisterRepo = vi.fn(); -const mockAddToGitignore = vi.fn(); +const mockEnsureGitNexusIgnored = vi.fn(); const mockGetGitRoot = vi.fn(); const mockIsGitRepo = vi.fn(); @@ -19,7 +19,7 @@ vi.mock('../../src/storage/repo-manager.js', () => ({ getStoragePaths: mockGetStoragePaths, loadMeta: mockLoadMeta, registerRepo: mockRegisterRepo, - addToGitignore: mockAddToGitignore, + ensureGitNexusIgnored: mockEnsureGitNexusIgnored, })); vi.mock('../../src/storage/git.js', () => ({ @@ -53,7 +53,7 @@ describe('indexCommand', () => { stats: { nodes: 10, edges: 20 }, }); mockAccess.mockResolvedValue(undefined); - mockAddToGitignore.mockResolvedValue(undefined); + mockEnsureGitNexusIgnored.mockResolvedValue(undefined); mockGetGitRoot.mockReturnValue(resolvedRepo); mockIsGitRepo.mockReturnValue(true); }); @@ -134,8 +134,8 @@ describe('indexCommand', () => { resolvedRepo, expect.objectContaining({ repoPath: resolvedRepo }), ); - expect(mockAddToGitignore).toHaveBeenCalledTimes(1); - expect(mockAddToGitignore).toHaveBeenCalledWith(resolvedRepo); + expect(mockEnsureGitNexusIgnored).toHaveBeenCalledTimes(1); + expect(mockEnsureGitNexusIgnored).toHaveBeenCalledWith(resolvedRepo); expect(process.exitCode).toBeUndefined(); }); @@ -170,7 +170,7 @@ describe('indexCommand', () => { resolvedRepo, expect.objectContaining({ repoPath: resolvedRepo }), ); - expect(mockAddToGitignore).toHaveBeenCalledWith(resolvedRepo); + expect(mockEnsureGitNexusIgnored).toHaveBeenCalledWith(resolvedRepo); expect(process.exitCode).toBeUndefined(); }); @@ -189,7 +189,7 @@ describe('indexCommand', () => { await indexCommand(['/repo', '/other']); expect(mockRegisterRepo).not.toHaveBeenCalled(); - expect(mockAddToGitignore).not.toHaveBeenCalled(); + expect(mockEnsureGitNexusIgnored).not.toHaveBeenCalled(); expect(process.exitCode).toBe(1); expect(logSpy).toHaveBeenCalledWith(' The `index` command accepts a single path only.'); }); diff --git a/gitnexus/test/unit/repo-manager.test.ts b/gitnexus/test/unit/repo-manager.test.ts index 12c56d67a..7169eb806 100644 --- a/gitnexus/test/unit/repo-manager.test.ts +++ b/gitnexus/test/unit/repo-manager.test.ts @@ -11,6 +11,7 @@ import fs from 'fs/promises'; import { getStoragePath, getStoragePaths, + ensureGitNexusIgnored, readRegistry, loadCLIConfig, registerRepo, @@ -62,6 +63,84 @@ describe('getStoragePaths', () => { }); }); +// ─── GitNexus ignore rules (#1233) ───────────────────────────────────── + +describe('ensureGitNexusIgnored (#1233)', () => { + let tmpRepo: Awaited>; + + beforeEach(async () => { + tmpRepo = await createTempDir('gitnexus-internal-gitignore-'); + }); + + afterEach(async () => { + await tmpRepo.cleanup(); + }); + + it('creates .gitnexus/.gitignore containing a catch-all ignore rule', async () => { + await ensureGitNexusIgnored(tmpRepo.dbPath); + + await expect( + fs.readFile(path.join(tmpRepo.dbPath, '.gitnexus', '.gitignore'), 'utf-8'), + ).resolves.toBe('*\n'); + }); + + it('does not create or modify the repository root .gitignore', async () => { + const rootGitignorePath = path.join(tmpRepo.dbPath, '.gitignore'); + await fs.writeFile(rootGitignorePath, 'node_modules/\n'); + + await ensureGitNexusIgnored(tmpRepo.dbPath); + + await expect(fs.readFile(rootGitignorePath, 'utf-8')).resolves.toBe('node_modules/\n'); + }); + + it('adds .gitnexus/ to .git/info/exclude when the repo has a real .git directory', async () => { + const excludePath = path.join(tmpRepo.dbPath, '.git', 'info', 'exclude'); + await fs.mkdir(path.dirname(excludePath), { recursive: true }); + + await ensureGitNexusIgnored(tmpRepo.dbPath); + + await expect(fs.readFile(excludePath, 'utf-8')).resolves.toBe('.gitnexus/\n'); + }); + + it('appends .gitnexus/ to .git/info/exclude once without disturbing existing rules', async () => { + const excludePath = path.join(tmpRepo.dbPath, '.git', 'info', 'exclude'); + await fs.mkdir(path.dirname(excludePath), { recursive: true }); + await fs.writeFile(excludePath, '# local excludes\nnode_modules/\n'); + + await ensureGitNexusIgnored(tmpRepo.dbPath); + await ensureGitNexusIgnored(tmpRepo.dbPath); + + await expect(fs.readFile(excludePath, 'utf-8')).resolves.toBe( + '# local excludes\nnode_modules/\n.gitnexus/\n', + ); + }); + + it('does not create .git/info/exclude when .git is not a directory', async () => { + await fs.writeFile(path.join(tmpRepo.dbPath, '.git'), 'gitdir: ../real-git-dir\n'); + + await ensureGitNexusIgnored(tmpRepo.dbPath); + + await expect(fs.access(path.join(tmpRepo.dbPath, '.git', 'info', 'exclude'))).rejects.toThrow(); + }); + + it('keeps generated .gitnexus files out of git status', async () => { + execSync('git init', { cwd: tmpRepo.dbPath, stdio: 'pipe' }); + execSync('git -c user.name=test -c user.email=test@test commit --allow-empty -m init', { + cwd: tmpRepo.dbPath, + stdio: 'pipe', + }); + + await ensureGitNexusIgnored(tmpRepo.dbPath); + await fs.writeFile(path.join(tmpRepo.dbPath, '.gitnexus', 'meta.json'), '{}\n'); + + const status = execSync('git status --short', { + cwd: tmpRepo.dbPath, + encoding: 'utf-8', + }); + expect(status).toBe(''); + }); +}); + // ─── readRegistry ──────────────────────────────────────────────────── describe('readRegistry', () => { diff --git a/gitnexus/test/unit/run-analyze.test.ts b/gitnexus/test/unit/run-analyze.test.ts index 747fffe4a..a688d82d0 100644 --- a/gitnexus/test/unit/run-analyze.test.ts +++ b/gitnexus/test/unit/run-analyze.test.ts @@ -1,5 +1,10 @@ +import { execSync } from 'child_process'; +import fs from 'fs/promises'; +import path from 'path'; import { describe, it, expect } from 'vitest'; import { deriveEmbeddingMode } from '../../src/core/embedding-mode.js'; +import { getStoragePaths, saveMeta, type RepoMeta } from '../../src/storage/repo-manager.js'; +import { createTempDir } from '../helpers/test-db.js'; describe('run-analyze module', () => { it('exports runFullAnalysis as a function', async () => { @@ -12,6 +17,44 @@ describe('run-analyze module', () => { expect(mod.PHASE_LABELS).toBeDefined(); expect(mod.PHASE_LABELS.parsing).toBe('Parsing code'); }); + + it('creates .gitnexus/.gitignore on the already-up-to-date fast path (#1233)', async () => { + const tmpRepo = await createTempDir('gitnexus-run-analyze-fast-path-'); + try { + execSync('git init', { cwd: tmpRepo.dbPath, stdio: 'pipe' }); + execSync('git -c user.name=test -c user.email=test@test commit --allow-empty -m init', { + cwd: tmpRepo.dbPath, + stdio: 'pipe', + }); + const currentCommit = execSync('git rev-parse HEAD', { + cwd: tmpRepo.dbPath, + encoding: 'utf-8', + }).trim(); + const { storagePath } = getStoragePaths(tmpRepo.dbPath); + const meta: RepoMeta = { + repoPath: tmpRepo.dbPath, + lastCommit: currentCommit, + indexedAt: new Date().toISOString(), + }; + await saveMeta(storagePath, meta); + + const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); + const result = await runFullAnalysis( + tmpRepo.dbPath, + {}, + { + onProgress: () => {}, + }, + ); + + expect(result.alreadyUpToDate).toBe(true); + await expect( + fs.readFile(path.join(tmpRepo.dbPath, '.gitnexus', '.gitignore'), 'utf-8'), + ).resolves.toBe('*\n'); + } finally { + await tmpRepo.cleanup(); + } + }); }); describe('deriveEmbeddingMode', () => { diff --git a/gitnexus/test/unit/skip-git-cli.test.ts b/gitnexus/test/unit/skip-git-cli.test.ts index c8c433f35..e82bb7b13 100644 --- a/gitnexus/test/unit/skip-git-cli.test.ts +++ b/gitnexus/test/unit/skip-git-cli.test.ts @@ -40,18 +40,19 @@ describe('--skip-git CLI flag', () => { describe('--skip-git does not walk up to parent git repo (#1232)', () => { const cliPath = path.resolve(__dirname, '../../dist/cli/index.js'); let parentDir: string; + let gitnexusHome: string; function testEnv() { return { ...process.env, HOME: parentDir, - GITNEXUS_HOME: path.join(parentDir, '.gitnexus-home'), + GITNEXUS_HOME: gitnexusHome, GITNEXUS_LBUG_EXTENSION_INSTALL: 'never', }; } function readRegistry(): Array<{ name: string; path: string }> { - const registryPath = path.join(parentDir, '.gitnexus-home', 'registry.json'); + const registryPath = path.join(gitnexusHome, 'registry.json'); expect(fs.existsSync(registryPath)).toBe(true); return JSON.parse(fs.readFileSync(registryPath, 'utf8')); } @@ -99,6 +100,7 @@ describe('--skip-git CLI flag', () => { // package.json // src/index.ts parentDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-skip-git-')); + gitnexusHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-skip-git-home-')); initParentGitRepo(); fs.mkdirSync(path.join(parentDir, 'COOLIO', 'src'), { recursive: true }); fs.writeFileSync( @@ -125,6 +127,9 @@ describe('--skip-git CLI flag', () => { if (parentDir) { fs.rmSync(parentDir, { recursive: true, force: true }); } + if (gitnexusHome) { + fs.rmSync(gitnexusHome, { recursive: true, force: true }); + } } it('from subdir inside parent git repo, indexes subdir not parent', () => { @@ -155,6 +160,36 @@ describe('--skip-git CLI flag', () => { } }); + it('keeps parent git status clean for --skip-git subdir analyze (#1233)', () => { + createTestStructure(); + try { + fs.writeFileSync(path.join(parentDir, '.gitignore'), '.claude/\n'); + execSync('git add .gitignore COOLIO SubWooder', { cwd: parentDir, stdio: 'ignore' }); + execSync('git -c user.name=test -c user.email=test@example.com commit -m fixtures', { + cwd: parentDir, + stdio: 'ignore', + }); + + execSync(`node "${cliPath}" analyze --skip-git --skip-agents-md`, { + cwd: path.join(parentDir, 'COOLIO'), + encoding: 'utf8', + timeout: 60000, + env: testEnv(), + }); + + expect( + fs.readFileSync(path.join(parentDir, 'COOLIO', '.gitnexus', '.gitignore'), 'utf8'), + ).toBe('*\n'); + const status = execSync('git status --short', { + cwd: parentDir, + encoding: 'utf8', + }); + expect(status).toBe(''); + } finally { + cleanup(); + } + }); + it('explicit input path with --skip-git indexes subdir', () => { createTestStructure(); try {