summaryrefslogtreecommitdiff
path: root/packages/core/src/utils
diff options
context:
space:
mode:
Diffstat (limited to 'packages/core/src/utils')
-rw-r--r--packages/core/src/utils/gitIgnoreParser.test.ts237
-rw-r--r--packages/core/src/utils/gitIgnoreParser.ts116
-rw-r--r--packages/core/src/utils/gitUtils.ts73
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;
+ }
+}