Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### New Features

- A new optional `.codegraphignore` file at your project root lets you override what gets indexed, taking precedence over the built-in defaults and every `.gitignore` (your repo's, nested ones, and even files git itself ignores). Use `!path/` to force-index code that's otherwise hidden — for example a monorepo whose real code lives in a directory the root `.gitignore` excludes, or behind a nested `.gitignore`. Force-include is code-aware: a broad `!app/` brings in that subtree's source but still leaves `node_modules`, `dist`, `.yarn`, and similar dependency/build dirs out unless you name one explicitly. Plain lines (no `!`) force-exclude, and the last matching line wins, so you can re-include a tree and then trim a few files back out. (#511)


## [0.9.8] - 2026-06-01

Expand Down
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -565,6 +565,37 @@ add a negation — `!vendor/`. The defaults apply uniformly, so committing a
dependency or build directory doesn't force it into the graph; the `.gitignore`
negation is the explicit opt-in.

### `.codegraphignore` — overriding what gets indexed

For cases a `.gitignore` negation can't reach — code hidden by the **root**
`.gitignore`, by a **nested** `.gitignore`, by an **embedded git repo**, or
otherwise invisible to git — drop a `.codegraphignore` at your project root. It
is the final authority on indexing scope, overriding the built-in defaults
**and** every `.gitignore`. Same syntax as `.gitignore`:

- `path` — **force-exclude** (drop it even if it would otherwise be indexed).
- `!path` — **force-include** (index it even if git/`.gitignore`/the defaults hide it).
- **Last matching line wins**, so you can re-include a tree and then trim a few files back out.

Force-include is **code-aware**: a broad `!app/` brings in that subtree's
*source*, but still leaves built-in dependency/build dirs (`node_modules`,
`dist`, `.yarn`, …) out — unless you name one explicitly (`!app/node_modules/mypkg/`).

Example — a monorepo whose real code lives under `environment/`, which the root
`.gitignore` excludes and whose own `.gitignore` further hides `src/app-*` and
`src/common`:

```gitignore
# .codegraphignore
!environment/ # index environment's code (deps stay excluded)

environment/.idea/ # trim tooling noise a broad include pulls in
environment/.pnp.cjs
```

A `.codegraphignore` with a force-include automatically scans via the
filesystem (rather than git), since git can't list the files it ignores.

## Supported Platforms

Every release ships a self-contained build (bundled Node runtime — nothing to
Expand Down
204 changes: 204 additions & 0 deletions __tests__/codegraphignore.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
/**
* .codegraphignore Tests
*
* The project-root `.codegraphignore` is the final authority over what the
* indexer includes — it overrides the built-in default-ignores AND every
* `.gitignore` (root, nested, and git's own view). Force-include is code-aware:
* a broad `!dir/` re-includes that subtree's source but NOT built-in dependency
* dirs (node_modules, dist, …) unless an anchor reaches into them specifically.
*
* These tests exercise both scan paths: the filesystem walk (non-git temp dirs)
* and the git fast path (real `git init` repos, where force-include must route
* the scan onto the walk so git-ignored files can be resurfaced).
*/

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { execFileSync } from 'child_process';
import { scanDirectory, loadCodegraphOverride } from '../src/extraction';

function createTempDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-cgi-'));
}

function cleanupTempDir(dir: string): void {
if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true });
}

function write(root: string, rel: string, content = 'export const x = 1;\n'): void {
const full = path.join(root, rel);
fs.mkdirSync(path.dirname(full), { recursive: true });
fs.writeFileSync(full, content);
}

function gitInit(dir: string): void {
const opts = { cwd: dir, stdio: 'pipe' as const };
execFileSync('git', ['init', '-q'], opts);
execFileSync('git', ['config', 'user.email', 'test@test.com'], opts);
execFileSync('git', ['config', 'user.name', 'Test'], opts);
}

function gitCommitAll(dir: string): void {
const opts = { cwd: dir, stdio: 'pipe' as const };
execFileSync('git', ['add', '-A'], opts);
execFileSync('git', ['commit', '-q', '-m', 'init'], opts);
}

describe('.codegraphignore — loader', () => {
let tempDir: string;
beforeEach(() => { tempDir = createTempDir(); });
afterEach(() => { cleanupTempDir(tempDir); });

it('returns null when the file is absent', () => {
expect(loadCodegraphOverride(tempDir)).toBeNull();
});

it('returns null for an empty or comments-only file', () => {
fs.writeFileSync(path.join(tempDir, '.codegraphignore'), '\n \n# just a comment\n');
expect(loadCodegraphOverride(tempDir)).toBeNull();
});

it('reports hasForceInclude only when a "!" line exists', () => {
fs.writeFileSync(path.join(tempDir, '.codegraphignore'), 'build/\n');
expect(loadCodegraphOverride(tempDir)?.hasForceInclude).toBe(false);

fs.writeFileSync(path.join(tempDir, '.codegraphignore'), '!vendor/\n');
expect(loadCodegraphOverride(tempDir)?.hasForceInclude).toBe(true);
});
});

describe('.codegraphignore — filesystem walk (non-git)', () => {
let tempDir: string;
beforeEach(() => { tempDir = createTempDir(); });
afterEach(() => { cleanupTempDir(tempDir); });

it('force-excludes a path that would otherwise be indexed', () => {
write(tempDir, 'src/keep.ts');
write(tempDir, 'src/drop.ts');
fs.writeFileSync(path.join(tempDir, '.codegraphignore'), 'src/drop.ts\n');

const files = scanDirectory(tempDir);
expect(files).toContain('src/keep.ts');
expect(files).not.toContain('src/drop.ts');
});

it('force-includes a file hidden by the root .gitignore', () => {
write(tempDir, 'src/app.ts');
write(tempDir, 'generated/out.ts');
fs.writeFileSync(path.join(tempDir, '.gitignore'), 'generated/\n');
fs.writeFileSync(path.join(tempDir, '.codegraphignore'), '!generated/\n');

const files = scanDirectory(tempDir);
expect(files).toContain('src/app.ts');
expect(files).toContain('generated/out.ts');
});

it('force-includes files hidden by a NESTED .gitignore', () => {
write(tempDir, 'app/src/app-admin/index.ts');
write(tempDir, 'app/src/common/util.ts');
// nested gitignore (relative to app/) hides the very dirs we want
fs.writeFileSync(path.join(tempDir, 'app', '.gitignore'), 'src/app-*\nsrc/common\n');
fs.writeFileSync(path.join(tempDir, '.codegraphignore'), '!app/src/\n');

const files = scanDirectory(tempDir);
expect(files).toContain('app/src/app-admin/index.ts');
expect(files).toContain('app/src/common/util.ts');
});

it('descends into a dir excluded by .gitignore to reach a buried include', () => {
// Mirrors the real repo: root ignores `environment`, which itself ignores
// src/app-* and src/common; `!environment/src/` must reach all of it.
write(tempDir, 'environment/src/app-fund/page.ts');
write(tempDir, 'environment/src/common/links.ts');
write(tempDir, 'environment/src/base/main.ts');
fs.writeFileSync(path.join(tempDir, '.gitignore'), 'environment\n');
fs.writeFileSync(path.join(tempDir, 'environment', '.gitignore'), 'src/app-*\nsrc/common\n');
fs.writeFileSync(path.join(tempDir, '.codegraphignore'), '!environment/src/\n');

const files = scanDirectory(tempDir);
expect(files).toContain('environment/src/app-fund/page.ts');
expect(files).toContain('environment/src/common/links.ts');
expect(files).toContain('environment/src/base/main.ts');
});

it('code-aware: a broad include indexes code but NOT built-in dep dirs', () => {
write(tempDir, 'environment/src/app-fund/page.ts');
write(tempDir, 'environment/vite.config.ts');
write(tempDir, 'environment/node_modules/pkg/index.js');
write(tempDir, 'environment/dist/bundle.js');
fs.writeFileSync(path.join(tempDir, '.gitignore'), 'environment\n');
fs.writeFileSync(path.join(tempDir, '.codegraphignore'), '!environment/\n');

const files = scanDirectory(tempDir);
expect(files).toContain('environment/src/app-fund/page.ts');
expect(files).toContain('environment/vite.config.ts'); // config IS code
expect(files.some((f) => f.includes('node_modules'))).toBe(false);
expect(files.some((f) => f.includes('/dist/') || f.startsWith('environment/dist/'))).toBe(false);
});

it('code-aware: an explicit anchor reaches surgically INTO a dep dir', () => {
write(tempDir, 'env/node_modules/keep/index.js');
write(tempDir, 'env/node_modules/other/index.js');
fs.writeFileSync(path.join(tempDir, '.gitignore'), 'env\n');
fs.writeFileSync(
path.join(tempDir, '.codegraphignore'),
'!env/\n!env/node_modules/keep/\n'
);

const files = scanDirectory(tempDir);
expect(files).toContain('env/node_modules/keep/index.js');
expect(files).not.toContain('env/node_modules/other/index.js');
});

it('does not affect an unrelated project (regression guard)', () => {
write(tempDir, 'src/a.ts');
write(tempDir, 'lib/b.ts');
fs.writeFileSync(path.join(tempDir, '.codegraphignore'), '!only-this-dir/\n');

// Force-include of a non-existent dir must not stop normal dirs being walked.
const files = scanDirectory(tempDir);
expect(files).toContain('src/a.ts');
expect(files).toContain('lib/b.ts');
});
});

describe('.codegraphignore — git fast path', () => {
let tempDir: string;
beforeEach(() => { tempDir = createTempDir(); });
afterEach(() => { cleanupTempDir(tempDir); });

it('routes to the walk and resurfaces a git-ignored file', () => {
const root = path.join(tempDir, 'repo');
fs.mkdirSync(root, { recursive: true });
gitInit(root);
write(root, 'src/tracked.ts');
write(root, 'secret/buried.ts');
fs.writeFileSync(path.join(root, '.gitignore'), 'secret/\n');
gitCommitAll(root); // secret/ is git-ignored, never committed

// Without override: git fast path drops the ignored file.
expect(scanDirectory(root)).not.toContain('secret/buried.ts');

// With force-include: routed to the walk, file resurfaces.
fs.writeFileSync(path.join(root, '.codegraphignore'), '!secret/\n');
const files = scanDirectory(root);
expect(files).toContain('src/tracked.ts');
expect(files).toContain('secret/buried.ts');
});

it('force-exclude works on the git fast path (no force-include present)', () => {
const root = path.join(tempDir, 'repo');
fs.mkdirSync(root, { recursive: true });
gitInit(root);
write(root, 'src/keep.ts');
write(root, 'src/drop.ts');
gitCommitAll(root);

fs.writeFileSync(path.join(root, '.codegraphignore'), 'src/drop.ts\n');
const files = scanDirectory(root);
expect(files).toContain('src/keep.ts');
expect(files).not.toContain('src/drop.ts');
});
});
Loading