diff --git a/src/extension.ts b/src/extension.ts index ac0182f..c8cc258 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -2,6 +2,7 @@ import * as vscode from 'vscode'; import * as fs from 'fs'; import * as path from 'path'; import { JetTreeMarkViewProvider } from './JetTreeMarkViewProvider'; +import { collectDirectoryGitignorePatterns, shouldExcludeByGitignore, GitignorePattern } from './gitignore-utils'; // noinspection JSUnusedGlobalSymbols export function activate(ctx: vscode.ExtensionContext) { @@ -43,9 +44,20 @@ interface TreeNodeType { /** * Build a TreeNodeType *for* the directory itself, including its contents. + * @param dir Directory path + * @param parentPatterns Optional gitignore patterns from parent directories + * @param forceUncheck + * @returns TreeNodeType representing the directory */ -export function buildTreeNode(dir: string): TreeNodeType { +export function buildTreeNode(dir: string, parentPatterns: GitignorePattern[] = [], forceUncheck: boolean = false): TreeNodeType { const name = path.basename(dir) || dir; + + // Collect gitignore patterns for this directory + const directoryPatterns = collectDirectoryGitignorePatterns(dir); + + // Combine with parent patterns (parent patterns take precedence) + const allPatterns = [...directoryPatterns, ...parentPatterns]; + const node: TreeNodeType = { id: dir, name, @@ -61,17 +73,31 @@ export function buildTreeNode(dir: string): TreeNodeType { for (const entry of entries) { const fullPath = path.join(dir, entry.name); - if (entry.isDirectory()) { - node.children!.push(buildTreeNode(fullPath)); + const isDirectory = entry.isDirectory(); + + // Check if this file/folder should be excluded based on gitignore patterns + const shouldExclude = shouldExcludeByGitignore(fullPath, dir, allPatterns, isDirectory); + + if (isDirectory) { + // Process subdirectory, passing down the combined patterns + const childNode = buildTreeNode(fullPath, allPatterns, shouldExclude || forceUncheck); + + // If the directory itself matches gitignore patterns, mark it as unchecked + if (shouldExclude || forceUncheck) { + childNode.checked = false; + } + + node.children!.push(childNode); } else { + // Add file node node.children!.push({ id: fullPath, name: entry.name, type: 'file', - checked: true + checked: !shouldExclude && !forceUncheck // Set checked to false if it matches gitignore patterns }); } } return node; -} \ No newline at end of file +} diff --git a/src/gitignore-utils.ts b/src/gitignore-utils.ts new file mode 100644 index 0000000..77ced25 --- /dev/null +++ b/src/gitignore-utils.ts @@ -0,0 +1,199 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +/** + * Represents a parsed gitignore pattern + */ +export interface GitignorePattern { + pattern: string; + isNegated: boolean; + isDirectory: boolean; + isAbsolute: boolean; +} + +/** + * Parses a .gitignore file and returns an array of patterns + * @param gitignorePath Path to the .gitignore file + * @returns Array of parsed gitignore patterns + */ +export function parseGitignoreFile(gitignorePath: string): GitignorePattern[] { + if (!fs.existsSync(gitignorePath)) { + return []; + } + + const content = fs.readFileSync(gitignorePath, 'utf8'); + return parseGitignoreContent(content); +} + +/** + * Parses gitignore content and returns an array of patterns + * @param content Content of the .gitignore file + * @returns Array of parsed gitignore patterns + */ +export function parseGitignoreContent(content: string): GitignorePattern[] { + return content + .split('\n') + .map(line => line.trim()) + .filter(line => line && !line.startsWith('#')) // Remove empty lines and comments + .map(line => { + const isNegated = line.startsWith('!'); + const pattern = isNegated ? line.substring(1) : line; + const isDirectory = pattern.endsWith('/'); + const isAbsolute = pattern.startsWith('/') || pattern.startsWith('./'); + + return { + pattern: isAbsolute ? pattern.substring(pattern.startsWith('./') ? 2 : 1) : pattern, + isNegated, + isDirectory, + isAbsolute + }; + }); +} + +/** + * Checks if a file or directory matches any of the gitignore patterns + * @param filePath Path to the file or directory (relative to the directory containing the .gitignore) + * @param patterns Array of gitignore patterns + * @param isDirectory Whether the path is a directory + * @returns True if the file or directory should be ignored + */ +export function matchesGitignorePatterns( + filePath: string, + patterns: GitignorePattern[], + isDirectory: boolean +): boolean { + // Normalize path for matching + const normalizedPath = filePath.replace(/\\/g, '/'); + + // Start with not ignored, then apply patterns in order + let ignored = false; + + for (const pattern of patterns) { + // Skip directory-only patterns if this is a file + if (pattern.isDirectory && !isDirectory) { + continue; + } + + if (matchesPattern(normalizedPath, pattern, isDirectory)) { + // If pattern matches, set ignored based on whether it's negated + ignored = !pattern.isNegated; + } + } + + return ignored; +} + +/** + * Checks if a path matches a gitignore pattern + * @param normalizedPath Normalized path to check + * @param pattern Gitignore pattern + * @param isDirectory Whether the path is a directory + * @returns True if the path matches the pattern + */ +function matchesPattern( + normalizedPath: string, + pattern: GitignorePattern, + isDirectory: boolean +): boolean { + const patternStr = pattern.pattern.replace(/\\/g, '/'); + + // For directory patterns (ending with /), also check without the trailing slash + // when matching against directories + if (pattern.isDirectory && isDirectory && patternStr.endsWith('/')) { + const patternWithoutSlash = patternStr.slice(0, -1); + if (normalizedPath === patternWithoutSlash) { + return true; + } + } + + // Handle exact matches + if (!patternStr.includes('*')) { + if (pattern.isAbsolute) { + // For absolute patterns, match from the beginning + return normalizedPath === patternStr || + (isDirectory && normalizedPath.startsWith(patternStr + '/')); + } else { + // For relative patterns, match anywhere in the path + return normalizedPath === patternStr || + normalizedPath.endsWith('/' + patternStr) || + normalizedPath.includes('/' + patternStr + '/') || + (isDirectory && ( + normalizedPath.endsWith('/' + patternStr) || + normalizedPath.includes('/' + patternStr + '/') + )); + } + } + + // Handle wildcard patterns + const regexPattern = patternStr + .replace(/\./g, '\\.') // Escape dots + .replace(/\*/g, '.*') // Convert * to .* + .replace(/\?/g, '.'); // Convert ? to . + + const regex = pattern.isAbsolute + ? new RegExp(`^${regexPattern}$`) + : new RegExp(`(^|/)${regexPattern}$`); + + return regex.test(normalizedPath); +} + +/** + * Collects gitignore patterns from a specific directory + * @param dirPath Path to the directory + * @returns Array of gitignore patterns from this directory + */ +export function collectDirectoryGitignorePatterns(dirPath: string): GitignorePattern[] { + const gitignorePath = path.join(dirPath, '.gitignore'); + if (fs.existsSync(gitignorePath)) { + return parseGitignoreFile(gitignorePath); + } + return []; +} + +/** + * Collects all gitignore patterns that apply to a given directory + * @param dirPath Path to the directory + * @returns Array of gitignore patterns that apply to the directory + */ +export function collectGitignorePatterns(dirPath: string): GitignorePattern[] { + const patterns: GitignorePattern[] = []; + let currentDir = dirPath; + + // Collect patterns from all parent directories up to the root + while (true) { + const gitignorePath = path.join(currentDir, '.gitignore'); + if (fs.existsSync(gitignorePath)) { + const dirPatterns = parseGitignoreFile(gitignorePath); + patterns.push(...dirPatterns); + } + + const parentDir = path.dirname(currentDir); + if (parentDir === currentDir) { + break; // Reached the root + } + currentDir = parentDir; + } + + return patterns; +} + +/** + * Determines if a file or directory should be excluded based on gitignore patterns + * @param fullPath Full path to the file or directory + * @param baseDir Base directory for relative path calculation + * @param patterns Gitignore patterns to check against + * @param isDirectory Whether the path is a directory + * @returns True if the file or directory should be excluded + */ +export function shouldExcludeByGitignore( + fullPath: string, + baseDir: string, + patterns: GitignorePattern[], + isDirectory: boolean +): boolean { + // Calculate path relative to the base directory + const relativePath = path.relative(baseDir, fullPath).replace(/\\/g, '/'); + + // Check if the path matches any gitignore pattern + return matchesGitignorePatterns(relativePath, patterns, isDirectory); +} diff --git a/src/test/gitignore-utils.test.ts b/src/test/gitignore-utils.test.ts new file mode 100644 index 0000000..65df199 --- /dev/null +++ b/src/test/gitignore-utils.test.ts @@ -0,0 +1,337 @@ +import * as assert from 'assert'; +import * as path from 'path'; +import * as fs from 'fs'; +import * as os from 'os'; +import { + parseGitignoreContent, + parseGitignoreFile, + matchesGitignorePatterns, + collectDirectoryGitignorePatterns, + collectGitignorePatterns, + shouldExcludeByGitignore, + GitignorePattern +} from '../gitignore-utils'; +import { buildTreeNode } from '../extension'; + +suite('Gitignore Utils Test Suite', () => { + suite('parseGitignoreContent', () => { + test('parses empty content correctly', () => { + const patterns = parseGitignoreContent(''); + assert.strictEqual(patterns.length, 0, 'Empty content should result in empty patterns array'); + }); + + test('parses basic patterns correctly', () => { + const content = ` + # This is a comment + node_modules + *.log + !important.log + /dist + build/ + `; + const patterns = parseGitignoreContent(content); + + assert.strictEqual(patterns.length, 5, 'Should parse 5 patterns'); + + // Check node_modules pattern + const nodeModulesPattern = patterns.find(p => p.pattern === 'node_modules'); + assert.ok(nodeModulesPattern, 'node_modules pattern should exist'); + assert.strictEqual(nodeModulesPattern!.isNegated, false, 'node_modules should not be negated'); + assert.strictEqual(nodeModulesPattern!.isDirectory, false, 'node_modules should not be marked as directory'); + assert.strictEqual(nodeModulesPattern!.isAbsolute, false, 'node_modules should not be absolute'); + + // Check *.log pattern + const logPattern = patterns.find(p => p.pattern === '*.log'); + assert.ok(logPattern, '*.log pattern should exist'); + assert.strictEqual(logPattern!.isNegated, false, '*.log should not be negated'); + + // Check !important.log pattern + const importantLogPattern = patterns.find(p => p.pattern === 'important.log'); + assert.ok(importantLogPattern, 'important.log pattern should exist'); + assert.strictEqual(importantLogPattern!.isNegated, true, 'important.log should be negated'); + + // Check /dist pattern + const distPattern = patterns.find(p => p.pattern === 'dist'); + assert.ok(distPattern, 'dist pattern should exist'); + assert.strictEqual(distPattern!.isAbsolute, true, 'dist should be absolute'); + + // Check build/ pattern + const buildPattern = patterns.find(p => p.pattern === 'build/'); + assert.ok(buildPattern, 'build/ pattern should exist'); + assert.strictEqual(buildPattern!.isDirectory, true, 'build/ should be marked as directory'); + }); + }); + + suite('matchesGitignorePatterns', () => { + test('matches basic patterns correctly', () => { + const patterns: GitignorePattern[] = [ + { pattern: 'node_modules', isNegated: false, isDirectory: false, isAbsolute: false }, + { pattern: '*.log', isNegated: false, isDirectory: false, isAbsolute: false }, + { pattern: 'important.log', isNegated: true, isDirectory: false, isAbsolute: false }, + { pattern: 'dist', isNegated: false, isDirectory: false, isAbsolute: true }, + { pattern: 'build/', isNegated: false, isDirectory: true, isAbsolute: false } + ]; + + // Should match + assert.strictEqual( + matchesGitignorePatterns('node_modules', patterns, false), + true, + 'node_modules should match' + ); + + assert.strictEqual( + matchesGitignorePatterns('logs/error.log', patterns, false), + true, + '*.log should match error.log' + ); + + assert.strictEqual( + matchesGitignorePatterns('dist', patterns, false), + true, + 'dist should match' + ); + + assert.strictEqual( + matchesGitignorePatterns('build', patterns, true), + true, + 'build/ should match build directory' + ); + + // Should not match + assert.strictEqual( + matchesGitignorePatterns('important.log', patterns, false), + false, + 'important.log should not match due to negation' + ); + + assert.strictEqual( + matchesGitignorePatterns('src', patterns, false), + false, + 'src should not match any pattern' + ); + + assert.strictEqual( + matchesGitignorePatterns('build', patterns, false), + false, + 'build/ should not match build file (only directory)' + ); + }); + }); + + suite('Integration with file system', () => { + let tmpDir: string; + + suiteSetup(() => { + // Create a temp directory + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitignore-test-')); + + // Create a .gitignore file + fs.writeFileSync(path.join(tmpDir, '.gitignore'), ` + # Test gitignore file + node_modules + *.log + !important.log + /dist + build/ + `); + + // Create some files and directories + fs.writeFileSync(path.join(tmpDir, 'file.txt'), 'content'); + fs.writeFileSync(path.join(tmpDir, 'error.log'), 'error'); + fs.writeFileSync(path.join(tmpDir, 'important.log'), 'important'); + + fs.mkdirSync(path.join(tmpDir, 'src')); + fs.writeFileSync(path.join(tmpDir, 'src', 'index.js'), 'console.log("Hello")'); + + fs.mkdirSync(path.join(tmpDir, 'dist')); + fs.writeFileSync(path.join(tmpDir, 'dist', 'bundle.js'), 'bundled code'); + + fs.mkdirSync(path.join(tmpDir, 'build')); + fs.writeFileSync(path.join(tmpDir, 'build', 'output.js'), 'output'); + + fs.mkdirSync(path.join(tmpDir, 'node_modules')); + fs.writeFileSync(path.join(tmpDir, 'node_modules', 'package.json'), '{}'); + + // Create a subdirectory with its own .gitignore + fs.mkdirSync(path.join(tmpDir, 'subdir')); + fs.writeFileSync(path.join(tmpDir, 'subdir', '.gitignore'), ` + # Subdir gitignore + *.txt + !important.txt + `); + fs.writeFileSync(path.join(tmpDir, 'subdir', 'file.txt'), 'content'); + fs.writeFileSync(path.join(tmpDir, 'subdir', 'important.txt'), 'important'); + fs.writeFileSync(path.join(tmpDir, 'subdir', 'code.js'), 'code'); + }); + + suiteTeardown(() => { + // Clean up + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + test('parseGitignoreFile reads file correctly', () => { + const patterns = parseGitignoreFile(path.join(tmpDir, '.gitignore')); + assert.ok(patterns.length > 0, 'Should parse patterns from file'); + assert.ok(patterns.some(p => p.pattern === 'node_modules'), 'Should include node_modules pattern'); + }); + + test('collectDirectoryGitignorePatterns collects patterns from directory', () => { + const patterns = collectDirectoryGitignorePatterns(tmpDir); + assert.ok(patterns.length > 0, 'Should collect patterns from directory'); + assert.ok(patterns.some(p => p.pattern === 'node_modules'), 'Should include node_modules pattern'); + }); + + test('collectGitignorePatterns collects patterns from directory and parents', () => { + const patterns = collectGitignorePatterns(path.join(tmpDir, 'subdir')); + assert.ok(patterns.length > 0, 'Should collect patterns from directory and parents'); + assert.ok(patterns.some(p => p.pattern === '*.txt'), 'Should include *.txt pattern from subdir'); + }); + + test('shouldExcludeByGitignore correctly identifies excluded files', () => { + // Files that should be excluded + assert.strictEqual( + shouldExcludeByGitignore( + path.join(tmpDir, 'node_modules'), + tmpDir, + collectDirectoryGitignorePatterns(tmpDir), + true + ), + true, + 'node_modules directory should be excluded' + ); + + assert.strictEqual( + shouldExcludeByGitignore( + path.join(tmpDir, 'error.log'), + tmpDir, + collectDirectoryGitignorePatterns(tmpDir), + false + ), + true, + 'error.log should be excluded' + ); + + assert.strictEqual( + shouldExcludeByGitignore( + path.join(tmpDir, 'dist'), + tmpDir, + collectDirectoryGitignorePatterns(tmpDir), + true + ), + true, + 'dist directory should be excluded' + ); + + assert.strictEqual( + shouldExcludeByGitignore( + path.join(tmpDir, 'build'), + tmpDir, + collectDirectoryGitignorePatterns(tmpDir), + true + ), + true, + 'build directory should be excluded' + ); + + // Files that should not be excluded + assert.strictEqual( + shouldExcludeByGitignore( + path.join(tmpDir, 'important.log'), + tmpDir, + collectDirectoryGitignorePatterns(tmpDir), + false + ), + false, + 'important.log should not be excluded due to negation' + ); + + assert.strictEqual( + shouldExcludeByGitignore( + path.join(tmpDir, 'file.txt'), + tmpDir, + collectDirectoryGitignorePatterns(tmpDir), + false + ), + false, + 'file.txt should not be excluded' + ); + + assert.strictEqual( + shouldExcludeByGitignore( + path.join(tmpDir, 'src'), + tmpDir, + collectDirectoryGitignorePatterns(tmpDir), + true + ), + false, + 'src directory should not be excluded' + ); + }); + + test('buildTreeNode respects gitignore patterns', () => { + const tree = buildTreeNode(tmpDir); + + // Check that the tree has the correct structure + assert.strictEqual(tree.name, path.basename(tmpDir), 'Root node should have correct name'); + assert.strictEqual(tree.type, 'folder', 'Root node should be a folder'); + + // Find nodes for various files/directories + const findNode = (name: string) => { + return tree.children!.find(node => node.name === name); + }; + + // Files/directories that should be included but unchecked + const nodeModulesNode = findNode('node_modules'); + assert.ok(nodeModulesNode, 'node_modules should be in the tree'); + assert.strictEqual(nodeModulesNode!.checked, false, 'node_modules should be unchecked'); + + const errorLogNode = findNode('error.log'); + assert.ok(errorLogNode, 'error.log should be in the tree'); + assert.strictEqual(errorLogNode!.checked, false, 'error.log should be unchecked'); + + const distNode = findNode('dist'); + assert.ok(distNode, 'dist should be in the tree'); + assert.strictEqual(distNode!.checked, false, 'dist should be unchecked'); + + const buildNode = findNode('build'); + assert.ok(buildNode, 'build should be in the tree'); + assert.strictEqual(buildNode!.checked, false, 'build should be unchecked'); + + // Files/directories that should be included and checked + const importantLogNode = findNode('important.log'); + assert.ok(importantLogNode, 'important.log should be in the tree'); + assert.strictEqual(importantLogNode!.checked, true, 'important.log should be checked'); + + const fileTxtNode = findNode('file.txt'); + assert.ok(fileTxtNode, 'file.txt should be in the tree'); + assert.strictEqual(fileTxtNode!.checked, true, 'file.txt should be checked'); + + const srcNode = findNode('src'); + assert.ok(srcNode, 'src should be in the tree'); + assert.strictEqual(srcNode!.checked, true, 'src should be checked'); + + // Check subdirectory with its own gitignore + const subdirNode = findNode('subdir'); + assert.ok(subdirNode, 'subdir should be in the tree'); + assert.strictEqual(subdirNode!.checked, true, 'subdir should be checked'); + + // Find nodes in the subdirectory + const subdirChildren = subdirNode!.children!; + const findSubdirNode = (name: string) => { + return subdirChildren.find(node => node.name === name); + }; + + const subdirFileTxtNode = findSubdirNode('file.txt'); + assert.ok(subdirFileTxtNode, 'subdir/file.txt should be in the tree'); + assert.strictEqual(subdirFileTxtNode!.checked, false, 'subdir/file.txt should be unchecked'); + + const subdirImportantTxtNode = findSubdirNode('important.txt'); + assert.ok(subdirImportantTxtNode, 'subdir/important.txt should be in the tree'); + assert.strictEqual(subdirImportantTxtNode!.checked, true, 'subdir/important.txt should be checked'); + + const subdirCodeJsNode = findSubdirNode('code.js'); + assert.ok(subdirCodeJsNode, 'subdir/code.js should be in the tree'); + assert.strictEqual(subdirCodeJsNode!.checked, true, 'subdir/code.js should be checked'); + }); + }); +}); \ No newline at end of file