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
This commit is contained in:
Gergő Magyar 2026-05-01 16:46:05 +01:00 • committed by Evan Wang
parent b692d1691f
commit cc95c668f5
8 changed files with 209 additions and 28 deletions

View file

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

View file

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

View file

@ -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<IndexedRepo | null> =
};
/**
* 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<void> => {
const gitignorePath = path.join(repoPath, '.gitignore');
export const ensureGitNexusIgnored = async (repoPath: string): Promise<void> => {
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<void> => {
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) ───────────────────────

View file

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

View file

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

View file

@ -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<ReturnType<typeof createTempDir>>;
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', () => {

View file

@ -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', () => {

View file

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