mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-13 05:12:55 -07:00
feat: handle nested gitignore files (#7645)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
This commit is contained in:
@@ -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', () => {
|
describe('getIgnoredPatterns', () => {
|
||||||
it('should return the raw patterns added', async () => {
|
it('should return the raw patterns added', async () => {
|
||||||
await setupGitRepo();
|
await setupGitRepo();
|
||||||
|
|||||||
@@ -29,9 +29,38 @@ export class GitIgnoreParser implements GitIgnoreFilter {
|
|||||||
// Always ignore .git directory regardless of .gitignore content
|
// Always ignore .git directory regardless of .gitignore content
|
||||||
this.addPatterns(['.git']);
|
this.addPatterns(['.git']);
|
||||||
|
|
||||||
const patternFiles = ['.gitignore', path.join('.git', 'info', 'exclude')];
|
this.loadPatterns(path.join('.git', 'info', 'exclude'));
|
||||||
for (const pf of patternFiles) {
|
this.findAndLoadGitignoreFiles(this.projectRoot);
|
||||||
this.loadPatterns(pf);
|
}
|
||||||
|
|
||||||
|
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
|
// ignore file not found
|
||||||
return;
|
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 ?? '')
|
const patterns = (content ?? '')
|
||||||
.split('\n')
|
.split('\n')
|
||||||
.map((p) => p.trim())
|
.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);
|
this.addPatterns(patterns);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user