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
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -575,7 +575,8 @@ What it skips out of the box:
[supported stack](#supported-languages) — so the graph is your code, not
third-party noise. This holds even with no `.gitignore`.
- **Anything in your `.gitignore`** — honored in git repos via git, and in
non-git projects by reading `.gitignore` directly (root and nested).
non-git projects by reading `.gitignore` directly (root and nested). Add a
local `.ignore` next to `.gitignore` when you need CodeGraph-only overrides.
- **Files larger than 1 MB** — generated bundles, minified JS, vendored blobs.

To keep something else out, add it to `.gitignore`. To pull a default-excluded
Expand All @@ -584,6 +585,11 @@ 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.

If a path must stay gitignored but should still be indexed locally, put the
negation in `.ignore` instead. For example, `.gitignore` can keep `customer/`
out of git while `.ignore` contains `!customer/` so CodeGraph indexes that
customer source without changing repository semantics.

## Supported Platforms

Every release ships a self-contained build (bundled Node runtime — nothing to
Expand Down
20 changes: 20 additions & 0 deletions __tests__/extraction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4955,6 +4955,26 @@ describe('Directory Exclusion', () => {
expect(files.every((f) => !f.includes('node_modules'))).toBe(true);
});

it('should allow a root .ignore negation to re-include a gitignored directory', async () => {
const { execFileSync } = await import('child_process');
const git = (cwd: string, ...args: string[]) =>
execFileSync('git', args, { cwd, stdio: 'pipe' });

const root = path.join(tempDir, 'repo');
fs.mkdirSync(path.join(root, 'src'), { recursive: true });
fs.mkdirSync(path.join(root, 'customer'), { recursive: true });
git(root, 'init', '-q');
fs.writeFileSync(path.join(root, 'src', 'index.ts'), 'export const x = 1;');
fs.writeFileSync(path.join(root, 'customer', 'custom.ts'), 'export const custom = 1;');
fs.writeFileSync(path.join(root, '.gitignore'), 'customer/\n');
fs.writeFileSync(path.join(root, '.ignore'), '!customer/\n');

const files = scanDirectory(root);

expect(files).toContain('src/index.ts');
expect(files).toContain('customer/custom.ts');
});

it('should apply a nested .gitignore only to its own subtree', () => {
const appSrc = path.join(tempDir, 'app', 'src');
fs.mkdirSync(appSrc, { recursive: true });
Expand Down
18 changes: 18 additions & 0 deletions __tests__/watcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,24 @@ describe('FileWatcher', () => {

watcher.stop();
});

it('should sync a gitignored source file re-included by .ignore', async () => {
fs.writeFileSync(path.join(testDir, '.gitignore'), 'customer/\n');
fs.writeFileSync(path.join(testDir, '.ignore'), '!customer/\n');
fs.mkdirSync(path.join(testDir, 'customer'), { recursive: true });
fs.writeFileSync(path.join(testDir, 'customer', 'custom.ts'), 'export const custom = 1;');

const syncFn = vi.fn().mockResolvedValue({ filesChanged: 0, durationMs: 0 });
const watcher = newWatcher(syncFn, { debounceMs: 200 });
watcher.start();
await watcher.waitUntilReady();

__emitWatchEventForTests(testDir, 'customer/custom.ts');
await waitFor(() => syncFn.mock.calls.length > 0);
expect(syncFn).toHaveBeenCalled();

watcher.stop();
});
});

describe('pending file tracking (#403)', () => {
Expand Down
35 changes: 24 additions & 11 deletions src/extraction/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,8 @@ const DEFAULT_IGNORE_PATTERNS: string[] = [

/**
* An `ignore` matcher seeded with the built-in defaults, merged with the project's
* root .gitignore so a negation there (e.g. `!vendor/`) overrides a default. Shared
* root .gitignore and then root .ignore so a negation there (e.g. `!vendor/`)
* overrides an earlier exclusion. Shared
* by both enumeration paths so behavior is identical with or without git — and so
* the defaults apply to tracked files too (committing a dependency dir doesn't make
* it project code; the explicit `.gitignore` negation is the only opt-in).
Expand All @@ -171,8 +172,10 @@ export function buildDefaultIgnore(rootDir: string): Ignore {
try {
const rootGitignore = path.join(rootDir, '.gitignore');
if (fs.existsSync(rootGitignore)) ig.add(fs.readFileSync(rootGitignore, 'utf-8'));
const rootIgnore = path.join(rootDir, '.ignore');
if (fs.existsSync(rootIgnore)) ig.add(fs.readFileSync(rootIgnore, 'utf-8'));
} catch {
// Unreadable root .gitignore — the built-in defaults still apply.
// Unreadable root ignore files — the built-in defaults still apply.
}
return ig;
}
Expand Down Expand Up @@ -235,6 +238,8 @@ function collectGitFiles(repoDir: string, prefix: string, files: Set<string>): v
*/
function getGitVisibleFiles(rootDir: string): Set<string> | null {
try {
if (fs.existsSync(path.join(rootDir, '.ignore'))) return null;

// Check if the project directory is gitignored by a parent repo.
// When rootDir lives inside a parent git repo that ignores it,
// `git ls-files` returns nothing — fall back to filesystem walk.
Expand Down Expand Up @@ -393,8 +398,8 @@ function scanDirectoryWalk(
let count = 0;
const visitedDirs = new Set<string>();

// A .gitignore matcher scoped to the directory that declared it. Patterns in
// a nested .gitignore are relative to that directory, so we keep the dir
// A matcher scoped to the directory that declared ignore files. Patterns in
// nested .gitignore/.ignore files are relative to that directory, so we keep the dir
// alongside the matcher and test paths relative to it — mirroring how git
// applies .gitignore files at every level.
interface ScopedIgnore {
Expand All @@ -404,12 +409,20 @@ function scanDirectoryWalk(

const loadIgnore = (dir: string): ScopedIgnore | null => {
try {
const patterns: string[] = [];
const giPath = path.join(dir, '.gitignore');
if (fs.existsSync(giPath)) {
return { dir, ig: ignore().add(fs.readFileSync(giPath, 'utf-8')) };
patterns.push(fs.readFileSync(giPath, 'utf-8'));
}
const ignorePath = path.join(dir, '.ignore');
if (fs.existsSync(ignorePath)) {
patterns.push(fs.readFileSync(ignorePath, 'utf-8'));
}
if (patterns.length > 0) {
return { dir, ig: ignore().add(patterns) };
}
} catch {
// Unreadable .gitignore — treat as absent.
// Unreadable ignore files — treat as absent.
}
return null;
};
Expand Down Expand Up @@ -439,9 +452,9 @@ function scanDirectoryWalk(
}
visitedDirs.add(realDir);

// This directory's own .gitignore (if present) applies to everything below it.
// The root's .gitignore is already merged into the seeded base matcher (so a
// negation there can override a built-in default), so skip it here.
// This directory's own ignore files (if present) apply to everything below it.
// The root ignore files are already merged into the seeded base matcher (so a
// negation there can override an earlier exclusion), so skip them here.
const own = dir === rootDir ? null : loadIgnore(dir);
const active = own ? [...matchers, own] : matchers;

Expand Down Expand Up @@ -495,8 +508,8 @@ function scanDirectoryWalk(
}
}

// Seed a base matcher with the built-in default ignores (merged with the root
// .gitignore so a negation can override). Nested .gitignores still layer per-dir.
// Seed a base matcher with the built-in default ignores merged with the root
// .gitignore/.ignore overlay. Nested ignore files still layer per-dir.
walk(rootDir, [{ dir: rootDir, ig: buildDefaultIgnore(rootDir) }]);
return files;
}
Expand Down