diff options
Diffstat (limited to 'packages/core/src/utils')
| -rw-r--r-- | packages/core/src/utils/gitIgnoreParser.test.ts | 237 | ||||
| -rw-r--r-- | packages/core/src/utils/gitIgnoreParser.ts | 116 | ||||
| -rw-r--r-- | packages/core/src/utils/gitUtils.ts | 73 |
3 files changed, 426 insertions, 0 deletions
diff --git a/packages/core/src/utils/gitIgnoreParser.test.ts b/packages/core/src/utils/gitIgnoreParser.test.ts new file mode 100644 index 00000000..0b99115a --- /dev/null +++ b/packages/core/src/utils/gitIgnoreParser.test.ts @@ -0,0 +1,237 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import { GitIgnoreParser } from './gitIgnoreParser.js'; +import * as fs from 'fs/promises'; +import * as path from 'path'; + +// Mock fs module +vi.mock('fs/promises'); + +// Mock gitUtils module +vi.mock('./gitUtils.js', () => ({ + isGitRepository: vi.fn(() => true), + findGitRoot: vi.fn(() => '/test/project'), +})); + +describe('GitIgnoreParser', () => { + let parser: GitIgnoreParser; + const mockProjectRoot = '/test/project'; + + beforeEach(() => { + parser = new GitIgnoreParser(mockProjectRoot); + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('initialization', () => { + it('should initialize without errors when no .gitignore exists', async () => { + vi.mocked(fs.readFile).mockRejectedValue(new Error('ENOENT')); + + await expect(parser.initialize()).resolves.not.toThrow(); + }); + + it('should load .gitignore patterns when file exists', async () => { + const gitignoreContent = ` +# Comment +node_modules/ +*.log +dist +.env +`; + vi.mocked(fs.readFile).mockResolvedValue(gitignoreContent); + + await parser.initialize(); + const patterns = parser.getIgnoredPatterns(); + + expect(patterns).toContain('.git/**'); + expect(patterns).toContain('.git'); + expect(patterns).toContain('node_modules/**'); + expect(patterns).toContain('**/*.log'); + expect(patterns).toContain('**/dist'); + expect(patterns).toContain('**/.env'); + }); + + it('should handle git exclude file', async () => { + vi.mocked(fs.readFile).mockImplementation(async (filePath) => { + if (filePath === path.join(mockProjectRoot, '.gitignore')) { + throw new Error('ENOENT'); + } + if ( + filePath === path.join(mockProjectRoot, '.git', 'info', 'exclude') + ) { + return 'temp/\n*.tmp'; + } + throw new Error('Unexpected file'); + }); + + await parser.initialize(); + const patterns = parser.getIgnoredPatterns(); + + expect(patterns).toContain('temp/**'); + expect(patterns).toContain('**/*.tmp'); + }); + }); + + describe('pattern parsing', () => { + it('should handle directory patterns correctly', async () => { + const gitignoreContent = 'node_modules/\nbuild/\n'; + vi.mocked(fs.readFile).mockResolvedValue(gitignoreContent); + + await parser.initialize(); + const patterns = parser.getIgnoredPatterns(); + + expect(patterns).toContain('node_modules/**'); + expect(patterns).toContain('build/**'); + }); + + it('should handle file patterns correctly', async () => { + const gitignoreContent = '*.log\n.env\nconfig.json\n'; + vi.mocked(fs.readFile).mockResolvedValue(gitignoreContent); + + await parser.initialize(); + const patterns = parser.getIgnoredPatterns(); + + expect(patterns).toContain('**/*.log'); + expect(patterns).toContain('**/.env'); + expect(patterns).toContain('**/config.json'); + }); + + it('should skip comments and empty lines', async () => { + const gitignoreContent = ` +# This is a comment +*.log + +# Another comment +.env +`; + vi.mocked(fs.readFile).mockResolvedValue(gitignoreContent); + + await parser.initialize(); + const patterns = parser.getIgnoredPatterns(); + + expect(patterns).not.toContain('# This is a comment'); + expect(patterns).not.toContain('# Another comment'); + expect(patterns).toContain('**/*.log'); + expect(patterns).toContain('**/.env'); + }); + + it('should skip negation patterns for now', async () => { + const gitignoreContent = '*.log\n!important.log\n'; + vi.mocked(fs.readFile).mockResolvedValue(gitignoreContent); + + await parser.initialize(); + const patterns = parser.getIgnoredPatterns(); + + expect(patterns).toContain('**/*.log'); + expect(patterns).not.toContain('!important.log'); + }); + + it('should handle paths with slashes correctly', async () => { + const gitignoreContent = 'src/*.log\ndocs/temp/\n'; + vi.mocked(fs.readFile).mockResolvedValue(gitignoreContent); + + await parser.initialize(); + const patterns = parser.getIgnoredPatterns(); + + expect(patterns).toContain('src/*.log'); + expect(patterns).toContain('docs/temp/**'); + }); + }); + + describe('isIgnored', () => { + beforeEach(async () => { + const gitignoreContent = ` +node_modules/ +*.log +dist +.env +src/*.tmp +`; + vi.mocked(fs.readFile).mockResolvedValue(gitignoreContent); + await parser.initialize(); + }); + + it('should always ignore .git directory', () => { + expect(parser.isIgnored('.git')).toBe(true); + expect(parser.isIgnored('.git/config')).toBe(true); + expect(parser.isIgnored('.git/objects/abc123')).toBe(true); + }); + + it('should ignore files matching patterns', () => { + expect(parser.isIgnored('node_modules')).toBe(true); + expect(parser.isIgnored('node_modules/package')).toBe(true); + expect(parser.isIgnored('app.log')).toBe(true); + expect(parser.isIgnored('logs/app.log')).toBe(true); + expect(parser.isIgnored('dist')).toBe(true); + expect(parser.isIgnored('.env')).toBe(true); + expect(parser.isIgnored('config/.env')).toBe(true); + }); + + it('should ignore files with path-specific patterns', () => { + expect(parser.isIgnored('src/temp.tmp')).toBe(true); + expect(parser.isIgnored('other/temp.tmp')).toBe(false); + }); + + it('should not ignore files that do not match patterns', () => { + expect(parser.isIgnored('src/index.ts')).toBe(false); + expect(parser.isIgnored('README.md')).toBe(false); + expect(parser.isIgnored('package.json')).toBe(false); + }); + + it('should handle absolute paths correctly', () => { + const absolutePath = path.join( + mockProjectRoot, + 'node_modules', + 'package', + ); + expect(parser.isIgnored(absolutePath)).toBe(true); + }); + + it('should handle paths outside project root', () => { + const outsidePath = '/other/project/file.txt'; + expect(parser.isIgnored(outsidePath)).toBe(false); + }); + + it('should handle relative paths correctly', () => { + expect(parser.isIgnored('./node_modules')).toBe(true); + expect(parser.isIgnored('../file.txt')).toBe(false); + }); + + it('should normalize path separators on Windows', () => { + expect(parser.isIgnored('node_modules\\package')).toBe(true); + expect(parser.isIgnored('src\\temp.tmp')).toBe(true); + }); + }); + + describe('getIgnoredPatterns', () => { + it('should return a copy of patterns array', async () => { + const gitignoreContent = '*.log\n'; + vi.mocked(fs.readFile).mockResolvedValue(gitignoreContent); + + await parser.initialize(); + const patterns1 = parser.getIgnoredPatterns(); + const patterns2 = parser.getIgnoredPatterns(); + + expect(patterns1).not.toBe(patterns2); // Different array instances + expect(patterns1).toEqual(patterns2); // Same content + }); + + it('should always include .git patterns', async () => { + vi.mocked(fs.readFile).mockRejectedValue(new Error('ENOENT')); + + await parser.initialize(); + const patterns = parser.getIgnoredPatterns(); + + expect(patterns).toContain('.git/**'); + expect(patterns).toContain('.git'); + }); + }); +}); diff --git a/packages/core/src/utils/gitIgnoreParser.ts b/packages/core/src/utils/gitIgnoreParser.ts new file mode 100644 index 00000000..098281ca --- /dev/null +++ b/packages/core/src/utils/gitIgnoreParser.ts @@ -0,0 +1,116 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'fs/promises'; +import * as path from 'path'; +import { minimatch } from 'minimatch'; +import { isGitRepository } from './gitUtils.js'; + +export interface GitIgnoreFilter { + isIgnored(filePath: string): boolean; + getIgnoredPatterns(): string[]; +} + +export class GitIgnoreParser implements GitIgnoreFilter { + private ignorePatterns: string[] = []; + private projectRoot: string; + private isGitRepo: boolean = false; + + constructor(projectRoot: string) { + this.projectRoot = path.resolve(projectRoot); + } + + async initialize(): Promise<void> { + this.isGitRepo = isGitRepository(this.projectRoot); + if (this.isGitRepo) { + const gitIgnoreFiles = [ + path.join(this.projectRoot, '.gitignore'), + path.join(this.projectRoot, '.git', 'info', 'exclude'), + ]; + + // Always ignore .git directory regardless of .gitignore content + this.ignorePatterns = ['.git/**', '.git']; + + for (const gitIgnoreFile of gitIgnoreFiles) { + try { + const content = await fs.readFile(gitIgnoreFile, 'utf-8'); + const patterns = this.parseGitIgnoreContent(content); + this.ignorePatterns.push(...patterns); + } catch (_error) { + // File doesn't exist or can't be read, continue silently + } + } + } + } + + private parseGitIgnoreContent(content: string): string[] { + return content + .split('\n') + .map((line) => line.trim()) + .filter((line) => line && !line.startsWith('#')) + .map((pattern) => { + // Handle negation patterns (!) - for now we'll skip them + if (pattern.startsWith('!')) { + return null; + } + + // Convert gitignore patterns to minimatch-compatible patterns + if (pattern.endsWith('/')) { + // Directory pattern - match directory and all contents + const dirPattern = pattern.slice(0, -1); // Remove trailing slash + return [dirPattern, dirPattern + '/**']; + } + + // If pattern doesn't contain /, it should match at any level + if (!pattern.includes('/') && !pattern.startsWith('**/')) { + return '**/' + pattern; + } + + return pattern; + }) + .filter((pattern) => pattern !== null) + .flat() as string[]; + } + + isIgnored(filePath: string): boolean { + // If not a git repository, nothing is ignored + if (!this.isGitRepo) { + return false; + } + + // Normalize the input path (handle ./ prefixes) + let cleanPath = filePath; + if (cleanPath.startsWith('./')) { + cleanPath = cleanPath.slice(2); + } + + // Convert to relative path from project root + const relativePath = path.relative( + this.projectRoot, + path.resolve(this.projectRoot, cleanPath), + ); + + // Handle paths that go outside project root + if (relativePath.startsWith('..')) { + return false; + } + + // Normalize path separators for cross-platform compatibility + const normalizedPath = relativePath.replace(/\\/g, '/'); + + return this.ignorePatterns.some((pattern) => + minimatch(normalizedPath, pattern, { + dot: true, + matchBase: false, + flipNegate: false, + }), + ); + } + + getIgnoredPatterns(): string[] { + return [...this.ignorePatterns]; + } +} diff --git a/packages/core/src/utils/gitUtils.ts b/packages/core/src/utils/gitUtils.ts new file mode 100644 index 00000000..90ec27aa --- /dev/null +++ b/packages/core/src/utils/gitUtils.ts @@ -0,0 +1,73 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'fs'; +import * as path from 'path'; + +/** + * Checks if a directory is within a git repository + * @param directory The directory to check + * @returns true if the directory is in a git repository, false otherwise + */ +export function isGitRepository(directory: string): boolean { + try { + let currentDir = path.resolve(directory); + + while (true) { + const gitDir = path.join(currentDir, '.git'); + + // Check if .git exists (either as directory or file for worktrees) + if (fs.existsSync(gitDir)) { + return true; + } + + const parentDir = path.dirname(currentDir); + + // If we've reached the root directory, stop searching + if (parentDir === currentDir) { + break; + } + + currentDir = parentDir; + } + + return false; + } catch (_error) { + // If any filesystem error occurs, assume not a git repo + return false; + } +} + +/** + * Finds the root directory of a git repository + * @param directory Starting directory to search from + * @returns The git repository root path, or null if not in a git repository + */ +export function findGitRoot(directory: string): string | null { + try { + let currentDir = path.resolve(directory); + + while (true) { + const gitDir = path.join(currentDir, '.git'); + + if (fs.existsSync(gitDir)) { + return currentDir; + } + + const parentDir = path.dirname(currentDir); + + if (parentDir === currentDir) { + break; + } + + currentDir = parentDir; + } + + return null; + } catch (_error) { + return null; + } +} |
