GitNexus/gitnexus/test/integration/filesystem-walker.test.ts
ivkond fbff6d08c0
feat(ingestion): respect .gitignore and .gitnexusignore during file discovery (#231)
* 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>
2026-03-16 13:26:20 +00:00

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);
});
});
});