diff --git a/README.md b/README.md index 7c7b84a9..40e388c1 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts index 6d9af606..c98b0a0c 100644 --- a/__tests__/extraction.test.ts +++ b/__tests__/extraction.test.ts @@ -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 }); diff --git a/__tests__/watcher.test.ts b/__tests__/watcher.test.ts index b372fc3d..f6c32fa6 100644 --- a/__tests__/watcher.test.ts +++ b/__tests__/watcher.test.ts @@ -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)', () => { diff --git a/src/extraction/index.ts b/src/extraction/index.ts index 36569258..39f8409c 100644 --- a/src/extraction/index.ts +++ b/src/extraction/index.ts @@ -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). @@ -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; } @@ -235,6 +238,8 @@ function collectGitFiles(repoDir: string, prefix: string, files: Set): v */ function getGitVisibleFiles(rootDir: string): Set | 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. @@ -393,8 +398,8 @@ function scanDirectoryWalk( let count = 0; const visitedDirs = new Set(); - // 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 { @@ -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; }; @@ -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; @@ -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; }