mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-08 22:22:52 +00:00
* feat(ingestion): respect .gitignore and .gitnexusignore during file discovery Add support for excluding files from indexing based on .gitignore and .gitnexusignore patterns. Previously, GitNexus used only a hardcoded ignore list, causing significant index pollution in repositories with git-ignored directories containing code (e.g., Docker-mounted volumes). Changes: - Add `ignore` package for gitignore-spec pattern matching - Add `loadIgnoreRules()` to parse .gitignore + .gitnexusignore - Add `createIgnoreFilter()` returning glob-compatible IgnoreLike object - Integrate filter into glob's `ignore` option for directory-level pruning - Remove post-glob `.filter()` call (now handled during traversal) The hardcoded DEFAULT_IGNORE_LIST remains as fallback for non-git repos. Closes #228 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(ingestion): address review feedback on ignore filtering - Distinguish ENOENT vs EACCES in loadIgnoreRules (warn on permission errors) - Add GITNEXUS_NO_GITIGNORE env var to bypass .gitignore parsing - Fix bare-name pattern matching in childrenIgnored (check both with/without trailing slash) - Rename isIgnoredDirectory to isHardcodedIgnoredDirectory for clarity - Add clarifying comments for design decisions (D2 negation, D3 dot:false redundancy) - Add tests for bare-name patterns, file-glob patterns, EACCES handling, env var Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(ingestion): address second round of review feedback - G1: Document GITNEXUS_NO_GITIGNORE in `analyze --help` and log when active - G2: Add comment clarifying path-scurry POSIX normalization contract - G3: Add IgnoreOptions interface — env var now falls back, callers can pass `noGitignore` explicitly for testability and future CLI flag - G4: Add integration test verifying walkRepositoryPaths respects the env var Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(ingestion): gracefully skip files with unavailable tree-sitter grammars Port unsupported language resilience from PR #301 by @jecanore. - Make Kotlin import optional (like Swift) in parser-loader and parse-worker - Add worker-local isLanguageAvailable() with filePath param for tsx distinction - Track and log skipped files per language in both sequential and worker paths - Add skippedLanguages to ParseWorkerResult for worker→main aggregation - Add isLanguageAvailable unit tests Refs: #301, #155, #228 Co-Authored-By: jecanore <juan@housingbase.io> Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test(e2e): add ignore + language-skip end-to-end test with fixture repo Add a fixture repo (test/fixtures/ignore-and-skip-repo/) with .gitignore, .gitnexusignore, TypeScript source files, and a Swift file to exercise all three features end-to-end: - File discovery: verifies .gitignore excludes data/ and *.log, .gitnexusignore excludes vendor/, source files are discovered - Parsing: verifies TypeScript files produce Function nodes and DEFINES relationships, Swift files are skipped gracefully when grammar is unavailable Add the test to the standalone group in ci-integration.yml and coverage job. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(ci): move ignore-and-skip-e2e test to e2e group per review feedback Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(test): use temp directory instead of fixture for e2e ignore test The fixture's .gitignore prevented data/seed.json and debug.log from being committed — these files would be missing after checkout in CI. Switch to creating the entire test structure in a temp directory via beforeAll (matching filesystem-walker.test.ts pattern). This ensures all files exist regardless of git ignore rules. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(test): correct graph API usage in e2e ignore test Use graph.nodes property getter instead of graph.getNodes(), and check Function node filePath instead of non-existent File nodes (File nodes are created by processStructure, not processParsing). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * ci: add workflows permission to ci-integration.yml Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * ci: change workflows permission to write per review Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * ci: move workflows permission from ci-integration.yml to ci.yml caller Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(ci): fix Claude workflows for fork PRs, remove misplaced workflows perm Three issues prevented Claude from running on fork PRs: 1. claude-code-review.yml lacked workflows:write — push failed when fork PRs modify .github/workflows/ files 2. claude.yml had no fork PR support — checked out main and couldn't fetch the fork's branch from origin 3. Cleanup step unconditionally deleted branches even when push failed, breaking the concurrent claude.yml workflow Also removes workflows:write from ci.yml's integration job — CI tests don't need that permission. The permission belongs on the claude workflows that push fork branches. Changes: - Add workflows:write to both claude workflow permissions blocks - Add fork PR detection + branch push/cleanup to claude.yml - Add step id to push-fork; cleanup only runs if push succeeded - Pass branch names via env vars to prevent shell injection (security) - Add concurrency groups to prevent race conditions between workflows - Remove misplaced workflows:write from ci.yml integration job * fix(ci): use GitHub API for fork branch refs instead of git push GITHUB_TOKEN cannot have 'workflows' permission — it's only valid for PATs and GitHub Apps. This means git push fails whenever a fork PR modifies .github/workflows/ files. Replace git push with the GitHub REST API (POST/PATCH /git/refs) to create temporary branch refs. The API creates a pointer to the already-existing PR head commit without triggering the workflow file push protection. Similarly, cleanup uses DELETE /git/refs instead of git push --delete. Also removes the invalid 'workflows: write' from permissions blocks. --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: jecanore <juan@housingbase.io> Co-authored-by: Gergo Magyar <gergomagyar@icloud.com>
298 lines
12 KiB
TypeScript
298 lines
12 KiB
TypeScript
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
|
import fs from 'fs/promises';
|
|
import path from 'path';
|
|
import os from 'os';
|
|
import { walkRepositoryPaths, readFileContents } from '../../src/core/ingestion/filesystem-walker.js';
|
|
|
|
describe('filesystem-walker', () => {
|
|
let tmpDir: string;
|
|
|
|
beforeAll(async () => {
|
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-walker-test-'));
|
|
|
|
// Create test directory structure
|
|
await fs.mkdir(path.join(tmpDir, 'src'), { recursive: true });
|
|
await fs.mkdir(path.join(tmpDir, 'src', 'components'), { recursive: true });
|
|
await fs.mkdir(path.join(tmpDir, 'node_modules', 'lodash'), { recursive: true });
|
|
await fs.mkdir(path.join(tmpDir, '.git'), { recursive: true });
|
|
|
|
await fs.writeFile(path.join(tmpDir, 'src', 'index.ts'), 'export const main = () => {}');
|
|
await fs.writeFile(path.join(tmpDir, 'src', 'utils.ts'), 'export const helper = () => {}');
|
|
await fs.writeFile(path.join(tmpDir, 'src', 'components', 'Button.tsx'), 'export const Button = () => <div/>');
|
|
await fs.writeFile(path.join(tmpDir, 'node_modules', 'lodash', 'index.js'), 'module.exports = {}');
|
|
await fs.writeFile(path.join(tmpDir, '.git', 'HEAD'), 'ref: refs/heads/main');
|
|
await fs.writeFile(path.join(tmpDir, 'package.json'), '{}');
|
|
await fs.writeFile(path.join(tmpDir, 'src', 'image.png'), Buffer.from([0x89, 0x50, 0x4E, 0x47]));
|
|
});
|
|
|
|
afterAll(async () => {
|
|
try {
|
|
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
} catch { /* best-effort */ }
|
|
});
|
|
|
|
describe('walkRepositoryPaths', () => {
|
|
it('discovers source files', async () => {
|
|
const files = await walkRepositoryPaths(tmpDir);
|
|
const paths = files.map(f => f.path.replace(/\\/g, '/'));
|
|
expect(paths.some(p => p.includes('src/index.ts'))).toBe(true);
|
|
expect(paths.some(p => p.includes('src/utils.ts'))).toBe(true);
|
|
});
|
|
|
|
it('discovers nested files', async () => {
|
|
const files = await walkRepositoryPaths(tmpDir);
|
|
const paths = files.map(f => f.path.replace(/\\/g, '/'));
|
|
expect(paths.some(p => p.includes('components/Button.tsx'))).toBe(true);
|
|
});
|
|
|
|
it('skips node_modules', async () => {
|
|
const files = await walkRepositoryPaths(tmpDir);
|
|
const paths = files.map(f => f.path.replace(/\\/g, '/'));
|
|
expect(paths.every(p => !p.includes('node_modules'))).toBe(true);
|
|
});
|
|
|
|
it('skips .git directory', async () => {
|
|
const files = await walkRepositoryPaths(tmpDir);
|
|
const paths = files.map(f => f.path.replace(/\\/g, '/'));
|
|
expect(paths.every(p => !p.includes('.git/'))).toBe(true);
|
|
});
|
|
|
|
it('returns file sizes', async () => {
|
|
const files = await walkRepositoryPaths(tmpDir);
|
|
for (const file of files) {
|
|
expect(typeof file.size).toBe('number');
|
|
expect(file.size).toBeGreaterThan(0);
|
|
}
|
|
});
|
|
|
|
it('calls progress callback', async () => {
|
|
const onProgress = vi.fn();
|
|
await walkRepositoryPaths(tmpDir, onProgress);
|
|
expect(onProgress).toHaveBeenCalled();
|
|
});
|
|
|
|
// ─── Unhappy paths ────────────────────────────────────────────────
|
|
|
|
it('throws or returns empty for non-existent directory', async () => {
|
|
try {
|
|
const files = await walkRepositoryPaths('/nonexistent/path/xyz123');
|
|
// If it doesn't throw, it should return empty
|
|
expect(files).toEqual([]);
|
|
} catch (err: any) {
|
|
expect(err).toBeDefined();
|
|
}
|
|
});
|
|
|
|
it('returns empty for directory with only ignored files', async () => {
|
|
const emptyDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-walker-empty-'));
|
|
await fs.mkdir(path.join(emptyDir, '.git'), { recursive: true });
|
|
await fs.writeFile(path.join(emptyDir, '.git', 'HEAD'), 'ref: refs/heads/main');
|
|
|
|
try {
|
|
const files = await walkRepositoryPaths(emptyDir);
|
|
expect(files).toEqual([]);
|
|
} finally {
|
|
await fs.rm(emptyDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('returns empty for truly empty directory', async () => {
|
|
const emptyDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-walker-truly-empty-'));
|
|
try {
|
|
const files = await walkRepositoryPaths(emptyDir);
|
|
expect(files).toEqual([]);
|
|
} finally {
|
|
await fs.rm(emptyDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('.gitignore support', () => {
|
|
let gitignoreDir: string;
|
|
|
|
beforeAll(async () => {
|
|
gitignoreDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-walker-gitignore-'));
|
|
|
|
// Create directory structure
|
|
await fs.mkdir(path.join(gitignoreDir, 'src'), { recursive: true });
|
|
await fs.mkdir(path.join(gitignoreDir, 'data', 'cache'), { recursive: true });
|
|
await fs.mkdir(path.join(gitignoreDir, 'logs'), { recursive: true });
|
|
|
|
// Source files (should be indexed)
|
|
await fs.writeFile(path.join(gitignoreDir, 'src', 'index.ts'), 'export const main = () => {}');
|
|
await fs.writeFile(path.join(gitignoreDir, 'src', 'utils.ts'), 'export const helper = () => {}');
|
|
|
|
// Data files (should be ignored via .gitignore)
|
|
await fs.writeFile(path.join(gitignoreDir, 'data', 'cache', 'file.json'), '{}');
|
|
await fs.writeFile(path.join(gitignoreDir, 'logs', 'app.log'), 'log entry');
|
|
|
|
// .gitignore
|
|
await fs.writeFile(path.join(gitignoreDir, '.gitignore'), 'data/\nlogs/\n');
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await fs.rm(gitignoreDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('excludes directories listed in .gitignore', async () => {
|
|
const files = await walkRepositoryPaths(gitignoreDir);
|
|
const paths = files.map(f => f.path.replace(/\\/g, '/'));
|
|
|
|
// Source files should be present
|
|
expect(paths.some(p => p.includes('src/index.ts'))).toBe(true);
|
|
expect(paths.some(p => p.includes('src/utils.ts'))).toBe(true);
|
|
|
|
// Ignored directories should not be present
|
|
expect(paths.every(p => !p.includes('data/'))).toBe(true);
|
|
expect(paths.every(p => !p.includes('logs/'))).toBe(true);
|
|
});
|
|
|
|
it('still applies hardcoded ignore list alongside .gitignore', async () => {
|
|
// Add node_modules (hardcoded ignore) to verify both work
|
|
await fs.mkdir(path.join(gitignoreDir, 'node_modules', 'pkg'), { recursive: true });
|
|
await fs.writeFile(path.join(gitignoreDir, 'node_modules', 'pkg', 'index.js'), 'module.exports = {}');
|
|
|
|
const files = await walkRepositoryPaths(gitignoreDir);
|
|
const paths = files.map(f => f.path.replace(/\\/g, '/'));
|
|
|
|
expect(paths.every(p => !p.includes('node_modules'))).toBe(true);
|
|
expect(paths.every(p => !p.includes('data/'))).toBe(true);
|
|
|
|
await fs.rm(path.join(gitignoreDir, 'node_modules'), { recursive: true, force: true });
|
|
});
|
|
});
|
|
|
|
describe('.gitnexusignore support', () => {
|
|
let nexusignoreDir: string;
|
|
|
|
beforeAll(async () => {
|
|
nexusignoreDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-walker-nexusignore-'));
|
|
|
|
await fs.mkdir(path.join(nexusignoreDir, 'src'), { recursive: true });
|
|
await fs.mkdir(path.join(nexusignoreDir, 'local', 'grafana'), { recursive: true });
|
|
|
|
await fs.writeFile(path.join(nexusignoreDir, 'src', 'index.ts'), 'export const main = () => {}');
|
|
await fs.writeFile(path.join(nexusignoreDir, 'local', 'grafana', 'module.js'), 'var x = 1;');
|
|
|
|
// Only .gitnexusignore, no .gitignore
|
|
await fs.writeFile(path.join(nexusignoreDir, '.gitnexusignore'), 'local/\n');
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await fs.rm(nexusignoreDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('excludes directories listed in .gitnexusignore', async () => {
|
|
const files = await walkRepositoryPaths(nexusignoreDir);
|
|
const paths = files.map(f => f.path.replace(/\\/g, '/'));
|
|
|
|
expect(paths.some(p => p.includes('src/index.ts'))).toBe(true);
|
|
expect(paths.every(p => !p.includes('local/'))).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('combined .gitignore + .gitnexusignore', () => {
|
|
let combinedDir: string;
|
|
|
|
beforeAll(async () => {
|
|
combinedDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-walker-combined-'));
|
|
|
|
await fs.mkdir(path.join(combinedDir, 'src'), { recursive: true });
|
|
await fs.mkdir(path.join(combinedDir, 'data'), { recursive: true });
|
|
await fs.mkdir(path.join(combinedDir, 'local', 'plugins'), { recursive: true });
|
|
|
|
await fs.writeFile(path.join(combinedDir, 'src', 'index.ts'), 'export const main = () => {}');
|
|
await fs.writeFile(path.join(combinedDir, 'data', 'dump.json'), '{}');
|
|
await fs.writeFile(path.join(combinedDir, 'local', 'plugins', 'module.js'), 'var x = 1;');
|
|
|
|
await fs.writeFile(path.join(combinedDir, '.gitignore'), 'data/\n');
|
|
await fs.writeFile(path.join(combinedDir, '.gitnexusignore'), 'local/\n');
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await fs.rm(combinedDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('excludes directories from both files', async () => {
|
|
const files = await walkRepositoryPaths(combinedDir);
|
|
const paths = files.map(f => f.path.replace(/\\/g, '/'));
|
|
|
|
expect(paths.some(p => p.includes('src/index.ts'))).toBe(true);
|
|
expect(paths.every(p => !p.includes('data/'))).toBe(true);
|
|
expect(paths.every(p => !p.includes('local/'))).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('GITNEXUS_NO_GITIGNORE env var', () => {
|
|
let envDir: string;
|
|
|
|
beforeAll(async () => {
|
|
envDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-walker-noignore-'));
|
|
|
|
await fs.mkdir(path.join(envDir, 'src'), { recursive: true });
|
|
await fs.mkdir(path.join(envDir, 'data'), { recursive: true });
|
|
|
|
await fs.writeFile(path.join(envDir, 'src', 'index.ts'), 'export const main = () => {}');
|
|
await fs.writeFile(path.join(envDir, 'data', 'dump.json'), '{}');
|
|
|
|
await fs.writeFile(path.join(envDir, '.gitignore'), 'data/\n');
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await fs.rm(envDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('excludes gitignored directory by default', async () => {
|
|
const files = await walkRepositoryPaths(envDir);
|
|
const paths = files.map(f => f.path.replace(/\\/g, '/'));
|
|
expect(paths.every(p => !p.includes('data/'))).toBe(true);
|
|
});
|
|
|
|
it('includes gitignored directory when GITNEXUS_NO_GITIGNORE is set', async () => {
|
|
const original = process.env.GITNEXUS_NO_GITIGNORE;
|
|
process.env.GITNEXUS_NO_GITIGNORE = '1';
|
|
try {
|
|
const files = await walkRepositoryPaths(envDir);
|
|
const paths = files.map(f => f.path.replace(/\\/g, '/'));
|
|
expect(paths.some(p => p.includes('data/dump.json'))).toBe(true);
|
|
} finally {
|
|
if (original === undefined) {
|
|
delete process.env.GITNEXUS_NO_GITIGNORE;
|
|
} else {
|
|
process.env.GITNEXUS_NO_GITIGNORE = original;
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('readFileContents', () => {
|
|
it('reads file contents by relative paths', async () => {
|
|
const contents = await readFileContents(tmpDir, ['src/index.ts', 'src/utils.ts']);
|
|
expect(contents.get('src/index.ts')).toContain('main');
|
|
expect(contents.get('src/utils.ts')).toContain('helper');
|
|
});
|
|
|
|
it('handles empty path list', async () => {
|
|
const contents = await readFileContents(tmpDir, []);
|
|
expect(contents.size).toBe(0);
|
|
});
|
|
|
|
it('skips non-existent files gracefully', async () => {
|
|
const contents = await readFileContents(tmpDir, ['nonexistent.ts']);
|
|
expect(contents.size).toBe(0);
|
|
});
|
|
|
|
// ─── Unhappy paths ────────────────────────────────────────────────
|
|
|
|
it('skips multiple non-existent files gracefully', async () => {
|
|
const contents = await readFileContents(tmpDir, ['a.ts', 'b.ts', 'c.ts']);
|
|
expect(contents.size).toBe(0);
|
|
});
|
|
|
|
it('handles binary file content without crashing', async () => {
|
|
const contents = await readFileContents(tmpDir, ['src/image.png']);
|
|
// May return content or skip — should not throw
|
|
expect(contents.size).toBeLessThanOrEqual(1);
|
|
});
|
|
});
|
|
});
|