From 43af36f3fe8a43b6982e7c7aeab51d5a6b66b084 Mon Sep 17 00:00:00 2001 From: HichemTech Date: Sat, 3 May 2025 23:52:23 +0100 Subject: [PATCH 1/2] Integrate gitignore support into directory tree building Enhanced the `buildTreeNode` function to account for `.gitignore` patterns when generating directory trees. This ensures files and directories that match gitignore rules are excluded or marked as unchecked. - Added `collectDirectoryGitignorePatterns` and `shouldExcludeByGitignore` utility functions. - Updated `buildTreeNode` to handle gitignore patterns and pass them recursively. - Adjusted logic to mark files/folders as unchecked if excluded by gitignore rules. --- src/extension.ts | 36 ++++++-- src/gitignore-utils.ts | 190 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 221 insertions(+), 5 deletions(-) create mode 100644 src/gitignore-utils.ts 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..68fb791 --- /dev/null +++ b/src/gitignore-utils.ts @@ -0,0 +1,190 @@ +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, '/'); + + // 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); +} From 107e4b153c01417d8795f661f99898ae9c05bea7 Mon Sep 17 00:00:00 2001 From: HichemTech Date: Sun, 4 May 2025 00:08:20 +0100 Subject: [PATCH 2/2] Add comprehensive tests for gitignore utility functions Introduce unit and integration tests to validate gitignore utility functions, ensuring proper parsing, matching, and exclusion logic. These tests improve code reliability and prevent regressions. - Added tests for `parseGitignoreContent` to verify pattern parsing. - Tested `matchesGitignorePatterns` to validate inclusion/exclusion logic. - Implemented integration tests to check filesystem interactions. - Verified `collectGitignorePatterns` and `shouldExcludeByGitignore` behaviors. - Confirmed `buildTreeNode` respects gitignore patterns accurately. --- src/gitignore-utils.ts | 9 + src/test/gitignore-utils.test.ts | 337 +++++++++++++++++++++++++++++++ 2 files changed, 346 insertions(+) create mode 100644 src/test/gitignore-utils.test.ts diff --git a/src/gitignore-utils.ts b/src/gitignore-utils.ts index 68fb791..77ced25 100644 --- a/src/gitignore-utils.ts +++ b/src/gitignore-utils.ts @@ -97,6 +97,15 @@ function matchesPattern( ): 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) { 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