diff --git a/.gitignore b/.gitignore index 6f6bcd99dea..5a87ad65a6b 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,9 @@ bin/ # Local prompts and rules /local-prompts +# Roo temp files +.roo/temp/ + # Test environment .test_env .vscode-test/ diff --git a/src/services/code-index/processors/file-watcher.ts b/src/services/code-index/processors/file-watcher.ts index 9a1fc3c9af4..e263130ced7 100644 --- a/src/services/code-index/processors/file-watcher.ts +++ b/src/services/code-index/processors/file-watcher.ts @@ -22,7 +22,8 @@ import { import { codeParser } from "./parser" import { CacheManager } from "../cache-manager" import { generateNormalizedAbsolutePath, generateRelativeFilePath } from "../shared/get-relative-path" -import { isPathInIgnoredDirectory } from "../../glob/ignore-utils" +import { DIRS_TO_IGNORE } from "../../glob/constants" +import * as path from "path" /** * Implementation of the file watcher interface @@ -455,7 +456,7 @@ export class FileWatcher implements IFileWatcher { async processFile(filePath: string): Promise { try { // Check if file is in an ignored directory - if (isPathInIgnoredDirectory(filePath)) { + if (this.isPathInIgnoredDirectory(filePath)) { return { path: filePath, status: "skipped" as const, @@ -543,4 +544,23 @@ export class FileWatcher implements IFileWatcher { } } } + + /** + * Check if a path is within an ignored directory + */ + private isPathInIgnoredDirectory(filePath: string): boolean { + const normalizedPath = path.normalize(filePath) + const pathParts = normalizedPath.split(path.sep) + + return pathParts.some((part) => { + // Check if any part of the path matches an ignored directory + return DIRS_TO_IGNORE.some((dir) => { + if (dir === ".*") { + // Special case for hidden directories (starting with .) + return part.startsWith(".") && part !== "." + } + return part === dir + }) + }) + } } diff --git a/src/services/code-index/processors/scanner.ts b/src/services/code-index/processors/scanner.ts index f24650f4bcd..2144eac2ee7 100644 --- a/src/services/code-index/processors/scanner.ts +++ b/src/services/code-index/processors/scanner.ts @@ -23,7 +23,7 @@ import { PARSING_CONCURRENCY, BATCH_PROCESSING_CONCURRENCY, } from "../constants" -import { isPathInIgnoredDirectory } from "../../glob/ignore-utils" +import { DIRS_TO_IGNORE } from "../../glob/constants" export class DirectoryScanner implements IDirectoryScanner { constructor( @@ -68,8 +68,8 @@ export class DirectoryScanner implements IDirectoryScanner { const ext = path.extname(filePath).toLowerCase() const relativeFilePath = generateRelativeFilePath(filePath) - // Check if file is in an ignored directory using the shared helper - if (isPathInIgnoredDirectory(filePath)) { + // Check if file is in an ignored directory + if (this.isPathInIgnoredDirectory(filePath)) { return false } @@ -362,4 +362,23 @@ export class DirectoryScanner implements IDirectoryScanner { } } } + + /** + * Check if a path is within an ignored directory + */ + private isPathInIgnoredDirectory(filePath: string): boolean { + const normalizedPath = path.normalize(filePath) + const pathParts = normalizedPath.split(path.sep) + + return pathParts.some((part) => { + // Check if any part of the path matches an ignored directory + return DIRS_TO_IGNORE.some((dir) => { + if (dir === ".*") { + // Special case for hidden directories (starting with .) + return part.startsWith(".") && part !== "." + } + return part === dir + }) + }) + } } diff --git a/src/services/glob/__tests__/list-files.spec.ts b/src/services/glob/__tests__/list-files.spec.ts index 2a154248307..049730f0056 100644 --- a/src/services/glob/__tests__/list-files.spec.ts +++ b/src/services/glob/__tests__/list-files.spec.ts @@ -1,6 +1,38 @@ -import { describe, it, expect, vi } from "vitest" +import { vi, describe, it, expect, beforeEach } from "vitest" +import * as path from "path" +import * as fs from "fs" +import * as childProcess from "child_process" import { listFiles } from "../list-files" +// Mock ripgrep to avoid filesystem dependencies +vi.mock("../../ripgrep", () => ({ + getBinPath: vi.fn().mockResolvedValue("/mock/path/to/rg"), +})) + +// Mock vscode +vi.mock("vscode", () => ({ + env: { + appRoot: "/mock/app/root", + }, +})) + +// Mock filesystem operations +vi.mock("fs", () => ({ + promises: { + access: vi.fn().mockRejectedValue(new Error("Not found")), + readFile: vi.fn().mockResolvedValue(""), + readdir: vi.fn().mockResolvedValue([]), + }, +})) + +vi.mock("child_process", () => ({ + spawn: vi.fn(), +})) + +vi.mock("../../path", () => ({ + arePathsEqual: vi.fn().mockReturnValue(false), +})) + vi.mock("../list-files", async () => { const actual = await vi.importActual("../list-files") return { @@ -15,4 +47,304 @@ describe("listFiles", () => { expect(result).toEqual([[], false]) }) + + describe("Whitelist functionality", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("should list files in .roo/temp even when .roo is gitignored", async () => { + const mockSpawn = vi.mocked(childProcess.spawn) + const mockFs = vi.mocked(fs.promises) + + // Mock .gitignore file with .roo + mockFs.access.mockImplementation((path) => { + if ((path as string).endsWith(".gitignore")) { + return Promise.resolve() + } + return Promise.reject(new Error("Not found")) + }) + mockFs.readFile.mockImplementation((path) => { + if ((path as string).endsWith(".gitignore")) { + return Promise.resolve(".roo") + } + return Promise.resolve("") + }) + + // Mock directory structure + mockFs.readdir.mockImplementation((dirPath) => { + const resolvedPath = path.resolve(dirPath as string) + if (resolvedPath === path.resolve(".")) { + return Promise.resolve([ + { name: ".roo", isDirectory: () => true, isSymbolicLink: () => false }, + { name: "src", isDirectory: () => true, isSymbolicLink: () => false }, + ] as any) + } + if (resolvedPath === path.resolve(".roo")) { + return Promise.resolve([ + { name: "temp", isDirectory: () => true, isSymbolicLink: () => false }, + ] as any) + } + if (resolvedPath === path.resolve(".roo/temp")) { + return Promise.resolve([ + { name: "test.txt", isDirectory: () => false, isSymbolicLink: () => false }, + ] as any) + } + return Promise.resolve([]) + }) + + // Mock ripgrep process + const mockProcess = { + stdout: { + on: vi.fn((event, callback) => { + if (event === "data") { + setTimeout(() => callback(".roo/temp/test.txt\n"), 10) + } + }), + }, + stderr: { + on: vi.fn(), + }, + on: vi.fn((event, callback) => { + if (event === "close") { + setTimeout(() => callback(0), 20) + } + }), + kill: vi.fn(), + } + mockSpawn.mockReturnValue(mockProcess as any) + + const [files] = await listFiles(".", true, 100) + + // Should include the whitelisted file - check with normalized paths + const hasRooTempFile = files.some((file) => file.replace(/\\/g, "/").includes(".roo/temp/test.txt")) + expect(hasRooTempFile).toBe(true) + + // The directories should be included in the results - check with normalized paths + const hasRooDir = files.some((file) => file.replace(/\\/g, "/").includes(".roo/")) + const hasRooTempDir = files.some((file) => file.replace(/\\/g, "/").includes(".roo/temp/")) + expect(hasRooDir).toBe(true) + expect(hasRooTempDir).toBe(true) + }) + + it("should handle nested hidden directories correctly", async () => { + const mockSpawn = vi.mocked(childProcess.spawn) + const mockFs = vi.mocked(fs.promises) + + // Mock directory structure with nested hidden directories + mockFs.readdir.mockImplementation((dirPath) => { + const resolvedPath = path.resolve(dirPath as string) + if (resolvedPath === path.resolve(".roo/temp")) { + return Promise.resolve([ + { name: "subdir", isDirectory: () => true, isSymbolicLink: () => false }, + ] as any) + } + if (resolvedPath === path.resolve(".roo/temp/subdir")) { + return Promise.resolve([ + { name: ".hidden", isDirectory: () => true, isSymbolicLink: () => false }, + ] as any) + } + if (resolvedPath === path.resolve(".roo/temp/subdir/.hidden")) { + return Promise.resolve([ + { name: "file.txt", isDirectory: () => false, isSymbolicLink: () => false }, + ] as any) + } + return Promise.resolve([]) + }) + + // Mock ripgrep process + const mockProcess = { + stdout: { + on: vi.fn((event, callback) => { + if (event === "data") { + setTimeout(() => callback(".roo/temp/subdir/.hidden/file.txt\n"), 10) + } + }), + }, + stderr: { + on: vi.fn(), + }, + on: vi.fn((event, callback) => { + if (event === "close") { + setTimeout(() => callback(0), 20) + } + }), + kill: vi.fn(), + } + mockSpawn.mockReturnValue(mockProcess as any) + + const [files] = await listFiles(".roo/temp", true, 100) + + // Should include files in nested hidden directories under whitelisted paths + expect(files).toContain(".roo/temp/subdir/.hidden/file.txt") + }) + + it("should not whitelist other hidden directories", async () => { + const mockSpawn = vi.mocked(childProcess.spawn) + const mockFs = vi.mocked(fs.promises) + + // Mock directory structure + mockFs.readdir.mockImplementation((dirPath) => { + const resolvedPath = path.resolve(dirPath as string) + if (resolvedPath === path.resolve(".")) { + return Promise.resolve([ + { name: ".other-hidden", isDirectory: () => true, isSymbolicLink: () => false }, + { name: ".roo", isDirectory: () => true, isSymbolicLink: () => false }, + ] as any) + } + return Promise.resolve([]) + }) + + // Mock ripgrep process - should not return .other-hidden files + const mockProcess = { + stdout: { + on: vi.fn((event, callback) => { + if (event === "data") { + // Only return non-hidden files + setTimeout(() => callback(""), 10) + } + }), + }, + stderr: { + on: vi.fn(), + }, + on: vi.fn((event, callback) => { + if (event === "close") { + setTimeout(() => callback(0), 20) + } + }), + kill: vi.fn(), + } + mockSpawn.mockReturnValue(mockProcess as any) + + const [files] = await listFiles(".", true, 100) + + // Should not include files from other hidden directories + expect(files).not.toContain(".other-hidden/") + expect(files).not.toContain(".other-hidden/file.txt") + }) + + it("should respect whitelist even when parent directory is gitignored", async () => { + const mockSpawn = vi.mocked(childProcess.spawn) + const mockFs = vi.mocked(fs.promises) + + // Mock .gitignore with parent directory + mockFs.access.mockImplementation((path) => { + if ((path as string).endsWith(".gitignore")) { + return Promise.resolve() + } + return Promise.reject(new Error("Not found")) + }) + mockFs.readFile.mockImplementation((path) => { + if ((path as string).endsWith(".gitignore")) { + return Promise.resolve(".roo/") + } + return Promise.resolve("") + }) + + // Mock directory structure + mockFs.readdir.mockImplementation((dirPath) => { + const resolvedPath = path.resolve(dirPath as string) + if (resolvedPath === path.resolve(".")) { + return Promise.resolve([ + { name: ".roo", isDirectory: () => true, isSymbolicLink: () => false }, + ] as any) + } + if (resolvedPath === path.resolve(".roo")) { + return Promise.resolve([ + { name: "temp", isDirectory: () => true, isSymbolicLink: () => false }, + { name: "other", isDirectory: () => true, isSymbolicLink: () => false }, + ] as any) + } + return Promise.resolve([]) + }) + + // Mock ripgrep process + const mockProcess = { + stdout: { + on: vi.fn((event, callback) => { + if (event === "data") { + setTimeout(() => callback(".roo/temp/workflow.json\n"), 10) + } + }), + }, + stderr: { + on: vi.fn(), + }, + on: vi.fn((event, callback) => { + if (event === "close") { + setTimeout(() => callback(0), 20) + } + }), + kill: vi.fn(), + } + mockSpawn.mockReturnValue(mockProcess as any) + + const [files] = await listFiles(".", true, 100) + + // Should include whitelisted paths even when parent is gitignored + expect(files).toContain(".roo/temp/workflow.json") + }) + + it("should handle case-sensitive paths correctly on different platforms", async () => { + const mockSpawn = vi.mocked(childProcess.spawn) + const mockFs = vi.mocked(fs.promises) + + // Mock directory structure with different case variations + mockFs.readdir.mockImplementation((dirPath) => { + const resolvedPath = path.resolve(dirPath as string) + if (resolvedPath === path.resolve(".")) { + return Promise.resolve([ + { name: ".roo", isDirectory: () => true, isSymbolicLink: () => false }, + { name: ".Roo", isDirectory: () => true, isSymbolicLink: () => false }, + ] as any) + } + if (resolvedPath === path.resolve(".roo")) { + return Promise.resolve([ + { name: "temp", isDirectory: () => true, isSymbolicLink: () => false }, + ] as any) + } + if (resolvedPath === path.resolve(".Roo")) { + return Promise.resolve([ + { name: "Temp", isDirectory: () => true, isSymbolicLink: () => false }, + ] as any) + } + return Promise.resolve([]) + }) + + // Mock ripgrep process - should only return files from whitelisted .roo/temp + const mockProcess = { + stdout: { + on: vi.fn((event, callback) => { + if (event === "data") { + setTimeout(() => { + // Only return files from the whitelisted .roo/temp directory + callback(".roo/temp/file1.txt\n") + }, 10) + } + }), + }, + stderr: { + on: vi.fn(), + }, + on: vi.fn((event, callback) => { + if (event === "close") { + setTimeout(() => callback(0), 20) + } + }), + kill: vi.fn(), + } + mockSpawn.mockReturnValue(mockProcess as any) + + const [files] = await listFiles(".", true, 100) + + // On case-sensitive systems, .roo and .Roo are different directories + // The whitelist is for ".roo/temp" specifically + expect(files).toContain(".roo/temp/file1.txt") + + // .Roo/Temp should not be whitelisted as it's a different path on case-sensitive systems + const hasUpperCaseRoo = files.some((file) => file.includes(".Roo/")) + expect(hasUpperCaseRoo).toBe(false) + }) + }) }) diff --git a/src/services/glob/constants.ts b/src/services/glob/constants.ts index 1ddcc37df91..ba30ba4b64a 100644 --- a/src/services/glob/constants.ts +++ b/src/services/glob/constants.ts @@ -22,3 +22,10 @@ export const DIRS_TO_IGNORE = [ "Pods", ".*", ] + +/** + * List of directories that should always be visible in file listings, + * even if they are included in .gitignore or are hidden directories. + * This is necessary for directories that contain workflow files used by various modes. + */ +export const GITIGNORE_WHITELIST = [".roo/temp"] diff --git a/src/services/glob/ignore-utils.ts b/src/services/glob/ignore-utils.ts deleted file mode 100644 index 9c80375e667..00000000000 --- a/src/services/glob/ignore-utils.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { DIRS_TO_IGNORE } from "./constants" - -/** - * Checks if a file path should be ignored based on the DIRS_TO_IGNORE patterns. - * This function handles special patterns like ".*" for hidden directories. - * - * @param filePath The file path to check - * @returns true if the path should be ignored, false otherwise - */ -export function isPathInIgnoredDirectory(filePath: string): boolean { - // Normalize path separators - const normalizedPath = filePath.replace(/\\/g, "/") - const pathParts = normalizedPath.split("/") - - // Check each directory in the path against DIRS_TO_IGNORE - for (const part of pathParts) { - // Skip empty parts (from leading or trailing slashes) - if (!part) continue - - // Handle the ".*" pattern for hidden directories - if (DIRS_TO_IGNORE.includes(".*") && part.startsWith(".") && part !== ".") { - return true - } - - // Check for exact matches - if (DIRS_TO_IGNORE.includes(part)) { - return true - } - } - - // Check if path contains any ignored directory pattern - for (const dir of DIRS_TO_IGNORE) { - if (dir === ".*") { - // Already handled above - continue - } - - // Check if the directory appears in the path - if (normalizedPath.includes(`/${dir}/`)) { - return true - } - } - - return false -} diff --git a/src/services/glob/list-files.ts b/src/services/glob/list-files.ts index 3fb1b2e154c..fbd6a375faa 100644 --- a/src/services/glob/list-files.ts +++ b/src/services/glob/list-files.ts @@ -3,9 +3,10 @@ import * as path from "path" import * as fs from "fs" import * as childProcess from "child_process" import * as vscode from "vscode" +import ignore, { Ignore } from "ignore" import { arePathsEqual } from "../../utils/path" import { getBinPath } from "../../services/ripgrep" -import { DIRS_TO_IGNORE } from "./constants" +import { DIRS_TO_IGNORE, GITIGNORE_WHITELIST } from "./constants" /** * List files in a directory, with optional recursive traversal @@ -35,8 +36,8 @@ export async function listFiles(dirPath: string, recursive: boolean, limit: numb const files = await listFilesWithRipgrep(rgPath, dirPath, recursive, limit) // Get directories with proper filtering - const gitignorePatterns = await parseGitignoreFile(dirPath, recursive) - const directories = await listFilteredDirectories(dirPath, recursive, gitignorePatterns) + const gitignoreInstance = await loadGitignore(dirPath, recursive) + const directories = await listFilteredDirectories(dirPath, recursive, gitignoreInstance) // Combine and format the results return formatAndCombineResults(files, directories, limit) @@ -100,21 +101,28 @@ function buildRipgrepArgs(dirPath: string, recursive: boolean): string[] { // Base arguments to list files const args = ["--files", "--hidden", "--follow"] + // Add --no-ignore-vcs for whitelisted paths + if (isPathInWhitelist(dirPath)) { + args.push("--no-ignore-vcs") + } + if (recursive) { - return [...args, ...buildRecursiveArgs(), dirPath] + return [...args, ...buildRecursiveArgs(dirPath), dirPath] } else { - return [...args, ...buildNonRecursiveArgs(), dirPath] + return [...args, ...buildNonRecursiveArgs(dirPath), dirPath] } } /** * Build ripgrep arguments for recursive directory traversal */ -function buildRecursiveArgs(): string[] { - const args: string[] = [] +function buildRecursiveArgs(dirPath?: string): string[] { + // Skip all exclusion patterns for whitelisted paths + if (dirPath && isPathInWhitelist(dirPath)) { + return [] + } - // In recursive mode, respect .gitignore by default - // (ripgrep does this automatically) + const args: string[] = [] // Apply directory exclusions for recursive searches for (const dir of DIRS_TO_IGNORE) { @@ -127,7 +135,12 @@ function buildRecursiveArgs(): string[] { /** * Build ripgrep arguments for non-recursive directory listing */ -function buildNonRecursiveArgs(): string[] { +function buildNonRecursiveArgs(dirPath?: string): string[] { + // Skip all exclusion patterns for whitelisted paths + if (dirPath && isPathInWhitelist(dirPath)) { + return ["-g", "*", "--maxdepth", "1"] + } + const args: string[] = [] // For non-recursive, limit to the current directory level @@ -153,11 +166,11 @@ function buildNonRecursiveArgs(): string[] { } /** - * Parse the .gitignore file if it exists and is relevant + * Load the .gitignore file if it exists and is relevant */ -async function parseGitignoreFile(dirPath: string, recursive: boolean): Promise { +async function loadGitignore(dirPath: string, recursive: boolean): Promise { if (!recursive) { - return [] // Only needed for recursive mode + return null // Only needed for recursive mode } const absolutePath = path.resolve(dirPath) @@ -171,18 +184,17 @@ async function parseGitignoreFile(dirPath: string, recursive: boolean): Promise< .catch(() => false) if (!exists) { - return [] + return null } - // Read and parse .gitignore file + // Read and parse .gitignore file using the ignore package const content = await fs.promises.readFile(gitignorePath, "utf8") - return content - .split("\n") - .map((line) => line.trim()) - .filter((line) => line && !line.startsWith("#")) + const ig = ignore() + ig.add(content) + return ig } catch (err) { console.warn(`Error reading .gitignore: ${err}`) - return [] // Continue without gitignore patterns on error + return null // Continue without gitignore on error } } @@ -192,7 +204,7 @@ async function parseGitignoreFile(dirPath: string, recursive: boolean): Promise< async function listFilteredDirectories( dirPath: string, recursive: boolean, - gitignorePatterns: string[], + gitignoreInstance: Ignore | null, ): Promise { const absolutePath = path.resolve(dirPath) const directories: string[] = [] @@ -209,7 +221,7 @@ async function listFilteredDirectories( const fullDirPath = path.join(currentPath, dirName) // Check if this directory should be included - if (shouldIncludeDirectory(dirName, recursive, gitignorePatterns)) { + if (shouldIncludeDirectory(dirName, recursive, gitignoreInstance, fullDirPath, currentPath)) { // Add the directory to our results (with trailing slash) const formattedPath = fullDirPath.endsWith("/") ? fullDirPath : `${fullDirPath}/` directories.push(formattedPath) @@ -236,10 +248,20 @@ async function listFilteredDirectories( /** * Determine if a directory should be included in results based on filters */ -function shouldIncludeDirectory(dirName: string, recursive: boolean, gitignorePatterns: string[]): boolean { - // Skip hidden directories if configured to ignore them - if (dirName.startsWith(".") && DIRS_TO_IGNORE.includes(".*")) { - return false +function shouldIncludeDirectory( + dirName: string, + recursive: boolean, + gitignoreInstance: Ignore | null, + fullPath: string, + basePath: string, +): boolean { + // Allow parent directories of whitelisted paths + const normalizedPath = path.normalize(fullPath) + for (const whitelisted of GITIGNORE_WHITELIST) { + const normalizedWhitelisted = path.normalize(path.resolve(basePath, whitelisted)) + if (normalizedWhitelisted.startsWith(normalizedPath + path.sep) || normalizedWhitelisted === normalizedPath) { + return true + } } // Check against explicit ignore patterns @@ -247,66 +269,22 @@ function shouldIncludeDirectory(dirName: string, recursive: boolean, gitignorePa return false } - // Check against gitignore patterns in recursive mode - if (recursive && gitignorePatterns.length > 0 && isIgnoredByGitignore(dirName, gitignorePatterns)) { + // Special handling for hidden directories + if (dirName.startsWith(".") && DIRS_TO_IGNORE.includes(".*")) { return false } - return true -} - -/** - * Check if a directory is in our explicit ignore list - */ -function isDirectoryExplicitlyIgnored(dirName: string): boolean { - for (const pattern of DIRS_TO_IGNORE) { - // Exact name matching - if (pattern === dirName) { - return true - } + // Check against gitignore patterns in recursive mode + if (recursive && gitignoreInstance) { + // Get relative path from the base directory for gitignore checking + const relativePath = path.relative(basePath, fullPath).replace(/\\/g, "/") - // Path patterns that contain / - if (pattern.includes("/")) { - const pathParts = pattern.split("/") - if (pathParts[0] === dirName) { - return true - } + if (gitignoreInstance.ignores(relativePath)) { + return false } } - return false -} - -/** - * Check if a directory matches any gitignore patterns - */ -function isIgnoredByGitignore(dirName: string, gitignorePatterns: string[]): boolean { - for (const pattern of gitignorePatterns) { - // Directory patterns (ending with /) - if (pattern.endsWith("/")) { - const dirPattern = pattern.slice(0, -1) - if (dirName === dirPattern) { - return true - } - if (pattern.startsWith("**/") && dirName === dirPattern.slice(3)) { - return true - } - } - // Simple name patterns - else if (dirName === pattern) { - return true - } - // Wildcard patterns - else if (pattern.includes("*")) { - const regexPattern = pattern.replace(/\\/g, "\\\\").replace(/\./g, "\\.").replace(/\*/g, ".*") - const regex = new RegExp(`^${regexPattern}$`) - if (regex.test(dirName)) { - return true - } - } - } - - return false + return true } /** @@ -412,3 +390,38 @@ async function execRipgrep(rgPath: string, args: string[], limit: number): Promi } }) } + +/** + * Check if a path is whitelisted + * @param dirPath - Path to check + * @returns true if path is whitelisted + */ +function isPathInWhitelist(dirPath: string): boolean { + const normalizedPath = path.normalize(dirPath) + return GITIGNORE_WHITELIST.some((whitelisted) => { + const normalizedWhitelisted = path.normalize(whitelisted) + return normalizedPath.includes(normalizedWhitelisted) || normalizedWhitelisted.includes(normalizedPath) + }) +} + +/** + * Check if a directory is in our explicit ignore list + */ +function isDirectoryExplicitlyIgnored(dirName: string): boolean { + for (const pattern of DIRS_TO_IGNORE) { + // Exact name matching + if (pattern === dirName) { + return true + } + + // Path patterns that contain / + if (pattern.includes("/")) { + const pathParts = pattern.split("/") + if (pathParts[0] === dirName) { + return true + } + } + } + + return false +}