mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-04 02:31:36 +00:00
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:
parent
b692d1691f
commit
cc95c668f5
8 changed files with 209 additions and 28 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) ───────────────────────
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue