From abddd2b6eef51e342c15ed055523bd8a1af01049 Mon Sep 17 00:00:00 2001 From: Gaurav <39389231+gsquared94@users.noreply.github.com> Date: Wed, 3 Sep 2025 07:23:27 -0700 Subject: [PATCH] feat: handle nested gitignore files (#7645) Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- .../core/src/utils/gitIgnoreParser.test.ts | 74 ++++++++++++++ packages/core/src/utils/gitIgnoreParser.ts | 99 ++++++++++++++++++- 2 files changed, 169 insertions(+), 4 deletions(-) diff --git a/packages/core/src/utils/gitIgnoreParser.test.ts b/packages/core/src/utils/gitIgnoreParser.test.ts index 2330de1261..25faf88920 100644 --- a/packages/core/src/utils/gitIgnoreParser.test.ts +++ b/packages/core/src/utils/gitIgnoreParser.test.ts @@ -193,6 +193,80 @@ src/*.tmp }); }); + describe('nested .gitignore files', () => { + beforeEach(async () => { + await setupGitRepo(); + // Root .gitignore + await createTestFile('.gitignore', 'root-ignored.txt'); + // Nested .gitignore 1 + await createTestFile('a/.gitignore', '/b\nc'); + // Nested .gitignore 2 + await createTestFile('a/d/.gitignore', 'e.txt\nf/g'); + }); + + it('should handle nested .gitignore files correctly', async () => { + parser.loadGitRepoPatterns(); + + // From root .gitignore + expect(parser.isIgnored('root-ignored.txt')).toBe(true); + expect(parser.isIgnored('a/root-ignored.txt')).toBe(true); + + // From a/.gitignore: /b + expect(parser.isIgnored('a/b')).toBe(true); + expect(parser.isIgnored('b')).toBe(false); + expect(parser.isIgnored('a/x/b')).toBe(false); + + // From a/.gitignore: c + expect(parser.isIgnored('a/c')).toBe(true); + expect(parser.isIgnored('a/x/y/c')).toBe(true); + expect(parser.isIgnored('c')).toBe(false); + + // From a/d/.gitignore: e.txt + expect(parser.isIgnored('a/d/e.txt')).toBe(true); + expect(parser.isIgnored('a/d/x/e.txt')).toBe(true); + expect(parser.isIgnored('a/e.txt')).toBe(false); + + // From a/d/.gitignore: f/g + expect(parser.isIgnored('a/d/f/g')).toBe(true); + expect(parser.isIgnored('a/f/g')).toBe(false); + }); + + it('should correctly transform patterns from nested gitignore files', () => { + parser.loadGitRepoPatterns(); + const patterns = parser.getPatterns(); + + // From root .gitignore + expect(patterns).toContain('root-ignored.txt'); + + // From a/.gitignore + expect(patterns).toContain('/a/b'); // /b becomes /a/b + expect(patterns).toContain('/a/**/c'); // c becomes /a/**/c + + // From a/d/.gitignore + expect(patterns).toContain('/a/d/**/e.txt'); // e.txt becomes /a/d/**/e.txt + expect(patterns).toContain('/a/d/f/g'); // f/g becomes /a/d/f/g + }); + }); + + describe('precedence rules', () => { + it('should prioritize root .gitignore over .git/info/exclude', async () => { + await setupGitRepo(); + // Exclude all .log files + await createTestFile(path.join('.git', 'info', 'exclude'), '*.log'); + // But make an exception in the root .gitignore + await createTestFile('.gitignore', '!important.log'); + + parser.loadGitRepoPatterns(); + + expect(parser.isIgnored('some.log')).toBe(true); + expect(parser.isIgnored('important.log')).toBe(false); + expect(parser.isIgnored(path.join('subdir', 'some.log'))).toBe(true); + expect(parser.isIgnored(path.join('subdir', 'important.log'))).toBe( + false, + ); + }); + }); + describe('getIgnoredPatterns', () => { it('should return the raw patterns added', async () => { await setupGitRepo(); diff --git a/packages/core/src/utils/gitIgnoreParser.ts b/packages/core/src/utils/gitIgnoreParser.ts index 1eb5379974..9b1da74220 100644 --- a/packages/core/src/utils/gitIgnoreParser.ts +++ b/packages/core/src/utils/gitIgnoreParser.ts @@ -29,9 +29,38 @@ export class GitIgnoreParser implements GitIgnoreFilter { // Always ignore .git directory regardless of .gitignore content this.addPatterns(['.git']); - const patternFiles = ['.gitignore', path.join('.git', 'info', 'exclude')]; - for (const pf of patternFiles) { - this.loadPatterns(pf); + this.loadPatterns(path.join('.git', 'info', 'exclude')); + this.findAndLoadGitignoreFiles(this.projectRoot); + } + + private findAndLoadGitignoreFiles(dir: string): void { + const relativeDir = path.relative(this.projectRoot, dir); + + // For sub-directories, check if they are ignored before proceeding. + // The root directory (relativeDir === '') should not be checked. + if (relativeDir && this.isIgnored(relativeDir)) { + return; + } + + // Load patterns from .gitignore in the current directory + const gitignorePath = path.join(dir, '.gitignore'); + if (fs.existsSync(gitignorePath)) { + this.loadPatterns(path.relative(this.projectRoot, gitignorePath)); + } + + // Recurse into subdirectories + try { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.name === '.git') { + continue; + } + if (entry.isDirectory()) { + this.findAndLoadGitignoreFiles(path.join(dir, entry.name)); + } + } + } catch (_error) { + // ignore readdir errors } } @@ -44,10 +73,72 @@ export class GitIgnoreParser implements GitIgnoreFilter { // ignore file not found return; } + + // .git/info/exclude file patterns are relative to project root and not file directory + const isExcludeFile = + patternsFileName.replace(/\\/g, '/') === '.git/info/exclude'; + const relativeBaseDir = isExcludeFile + ? '.' + : path.dirname(patternsFileName); + const patterns = (content ?? '') .split('\n') .map((p) => p.trim()) - .filter((p) => p !== '' && !p.startsWith('#')); + .filter((p) => p !== '' && !p.startsWith('#')) + .map((p) => { + const isNegative = p.startsWith('!'); + if (isNegative) { + p = p.substring(1); + } + + const isAnchoredInFile = p.startsWith('/'); + if (isAnchoredInFile) { + p = p.substring(1); + } + + // An empty pattern can result from a negated pattern like `!`, + // which we can ignore. + if (p === '') { + return ''; + } + + let newPattern = p; + if (relativeBaseDir && relativeBaseDir !== '.') { + // Only in nested .gitignore files, the patterns need to be modified according to: + // - If `a/b/.gitignore` defines `/c` then it needs to be changed to `/a/b/c` + // - If `a/b/.gitignore` defines `c` then it needs to be changed to `/a/b/**/c` + // - If `a/b/.gitignore` defines `c/d` then it needs to be changed to `/a/b/c/d` + + if (!isAnchoredInFile && !p.includes('/')) { + // If no slash and not anchored in file, it matches files in any + // subdirectory. + newPattern = path.join('**', p); + } + + // Prepend the .gitignore file's directory. + newPattern = path.join(relativeBaseDir, newPattern); + + // Anchor the pattern to a nested gitignore directory. + if (!newPattern.startsWith('/')) { + newPattern = '/' + newPattern; + } + } + + // Anchor the pattern if originally anchored + if (isAnchoredInFile && !newPattern.startsWith('/')) { + newPattern = '/' + newPattern; + } + + if (isNegative) { + newPattern = '!' + newPattern; + } + + // Even in windows, Ignore expects forward slashes. + newPattern = newPattern.replace(/\\/g, '/'); + + return newPattern; + }) + .filter((p) => p !== ''); this.addPatterns(patterns); }