diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts index 158cda8..60cbd10 100644 --- a/__tests__/extraction.test.ts +++ b/__tests__/extraction.test.ts @@ -7350,6 +7350,105 @@ describe('Directory Exclusion', () => { }); }); + +describe('Nested .gitignore node_modules exclusion (#1567)', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = createTempDir(); + }); + + afterEach(() => { + cleanupTempDir(tempDir); + }); + + function plantNodeModules(subproject: string, packages = 80): void { + const base = path.join(tempDir, subproject, 'node_modules'); + for (let i = 0; i < packages; i++) { + const pkg = path.join(base, `pkg${i}`); + fs.mkdirSync(pkg, { recursive: true }); + fs.writeFileSync(path.join(pkg, 'index.js'), `module.exports = ${i};`); + fs.writeFileSync(path.join(pkg, 'index.d.ts'), 'export const n: number;'); + if (i % 4 === 0) fs.writeFileSync(path.join(pkg, '.gitignore'), '*.map\n'); + const nested = path.join(pkg, 'node_modules', `nested${i}`); + fs.mkdirSync(nested, { recursive: true }); + fs.writeFileSync(path.join(nested, 'lib.ts'), 'export const x = 1;'); + } + } + + function initGitRepo(): void { + const { execFileSync } = require('child_process') as typeof import('child_process'); + execFileSync('git', ['init'], { cwd: tempDir, stdio: 'ignore' }); + execFileSync('git', ['add', '-A'], { cwd: tempDir, stdio: 'ignore' }); + execFileSync( + 'git', + ['-c', 'user.email=test@example.com', '-c', 'user.name=Test', 'commit', '-m', 'init'], + { cwd: tempDir, stdio: 'ignore' }, + ); + } + + it('excludes node_modules ignored only by a nested .gitignore (git path)', () => { + fs.mkdirSync(path.join(tempDir, 'frontend', 'src'), { recursive: true }); + fs.mkdirSync(path.join(tempDir, 'extension', 'src'), { recursive: true }); + fs.writeFileSync(path.join(tempDir, 'frontend', 'src', 'app.ts'), 'export const a = 1;'); + fs.writeFileSync(path.join(tempDir, 'extension', 'src', 'ext.ts'), 'export const b = 1;'); + fs.writeFileSync(path.join(tempDir, 'root.ts'), 'export const r = 1;'); + fs.writeFileSync(path.join(tempDir, '.gitignore'), '*.log\n'); + fs.writeFileSync(path.join(tempDir, 'frontend', '.gitignore'), '/node_modules\n'); + fs.writeFileSync(path.join(tempDir, 'extension', '.gitignore'), 'node_modules/\n'); + plantNodeModules('frontend'); + plantNodeModules('extension'); + initGitRepo(); + + const files = scanDirectory(tempDir); + expect(files.sort()).toEqual(['extension/src/ext.ts', 'frontend/src/app.ts', 'root.ts']); + expect(files.every((f) => !f.includes('node_modules'))).toBe(true); + }); + + it('excludes nested-gitignore node_modules on the filesystem-walk fallback too', () => { + fs.mkdirSync(path.join(tempDir, 'frontend', 'src'), { recursive: true }); + fs.mkdirSync(path.join(tempDir, 'extension', 'src'), { recursive: true }); + fs.writeFileSync(path.join(tempDir, 'frontend', 'src', 'app.ts'), 'export const a = 1;'); + fs.writeFileSync(path.join(tempDir, 'extension', 'src', 'ext.ts'), 'export const b = 1;'); + fs.writeFileSync(path.join(tempDir, 'root.ts'), 'export const r = 1;'); + fs.writeFileSync(path.join(tempDir, '.gitignore'), '*.log\n'); + fs.writeFileSync(path.join(tempDir, 'frontend', '.gitignore'), '/node_modules\n'); + fs.writeFileSync(path.join(tempDir, 'extension', '.gitignore'), 'node_modules/\n'); + plantNodeModules('frontend', 60); + plantNodeModules('extension', 60); + + const files = scanDirectory(tempDir); + expect(files.sort()).toEqual(['extension/src/ext.ts', 'frontend/src/app.ts', 'root.ts']); + expect(files.every((f) => !f.includes('node_modules'))).toBe(true); + }); + + it('still excludes when root only lists one subproject node_modules (Boba-like)', () => { + fs.mkdirSync(path.join(tempDir, 'frontend', 'src'), { recursive: true }); + fs.mkdirSync(path.join(tempDir, 'extension', 'src'), { recursive: true }); + fs.writeFileSync(path.join(tempDir, 'frontend', 'src', 'app.ts'), 'export const a = 1;'); + fs.writeFileSync(path.join(tempDir, 'extension', 'src', 'ext.ts'), 'export const b = 1;'); + fs.writeFileSync(path.join(tempDir, 'root.ts'), 'export const r = 1;'); + fs.writeFileSync( + path.join(tempDir, '.gitignore'), + ['*.log', 'frontend/node_modules/', 'frontend/.angular/', ''].join('\n'), + ); + fs.writeFileSync(path.join(tempDir, 'frontend', '.gitignore'), '/node_modules\n'); + fs.writeFileSync(path.join(tempDir, 'extension', '.gitignore'), 'node_modules/\n'); + plantNodeModules('frontend', 40); + plantNodeModules('extension', 40); + + const fsFiles = scanDirectory(tempDir); + expect(fsFiles.every((f) => !f.includes('node_modules'))).toBe(true); + expect(fsFiles.sort()).toEqual(['extension/src/ext.ts', 'frontend/src/app.ts', 'root.ts']); + + initGitRepo(); + const gitFiles = scanDirectory(tempDir); + expect(gitFiles.every((f) => !f.includes('node_modules'))).toBe(true); + expect(gitFiles.sort()).toEqual(['extension/src/ext.ts', 'frontend/src/app.ts', 'root.ts']); + }); +}); + + describe('Git Submodules', () => { let tempDir: string; diff --git a/src/extraction/index.ts b/src/extraction/index.ts index 93be483..ffc1175 100644 --- a/src/extraction/index.ts +++ b/src/extraction/index.ts @@ -1050,6 +1050,10 @@ function getGitVisibleFiles(rootDir: string): Set | null { { cwd: rootDir, encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true } ); // Directory is gitignored by parent repo — fall back to filesystem walk + logDebug('project root is gitignored by a parent repo — falling back to filesystem walk', { + rootDir, + gitRoot, + }); return null; } catch { // Not ignored — safe to use git ls-files @@ -1074,7 +1078,20 @@ function getGitVisibleFiles(rootDir: string): Set | null { // Git, but still wanted in the graph.) for (const f of collectIncludedFilesForRoot(rootDir)) visible.add(f); return visible; - } catch { + } catch (error) { + // Any failure here (git missing, a `git rev-parse`/`ls-files` timeout or + // buffer overrun under load, an unreadable repo, unsupported flag combo on + // older git, etc.) silently sent every caller to `scanDirectoryWalk` with + // zero signal that the fast git-delegated path was skipped — making reports + // like #1567 (nested-`.gitignore`-excluded `node_modules` walked into) + // hard to triage, since both ignore implementations look correct in + // isolation but there was no way to tell which one ran. Log it under the + // existing CODEGRAPH_DEBUG gate so a future report can confirm or rule out + // the fallback in one step. + logDebug('git-based file listing unavailable — falling back to filesystem walk', { + rootDir, + error: error instanceof Error ? error.message : String(error), + }); return null; } }