diff --git a/src/utils/__tests__/cline-ignore.test.ts b/src/utils/__tests__/cline-ignore.test.ts new file mode 100644 index 0000000000..060a5c2292 --- /dev/null +++ b/src/utils/__tests__/cline-ignore.test.ts @@ -0,0 +1,73 @@ +import { shouldIgnorePath } from "../cline-ignore" + +describe("shouldIgnorePath", () => { + test("exact match pattern", () => { + const ignoreContent = "test.txt" + expect(shouldIgnorePath("test.txt", ignoreContent)).toBe(true) + expect(shouldIgnorePath("other.txt", ignoreContent)).toBe(false) + }) + + test("wildcard pattern", () => { + const ignoreContent = "*.txt" + expect(shouldIgnorePath("test.txt", ignoreContent)).toBe(true) + expect(shouldIgnorePath("test.js", ignoreContent)).toBe(false) + }) + + test("directory pattern", () => { + const ignoreContent = "node_modules/" + expect(shouldIgnorePath("node_modules/package.json", ignoreContent)).toBe(true) + expect(shouldIgnorePath("src/node_modules.ts", ignoreContent)).toBe(false) + }) + + test("comments and empty lines", () => { + const ignoreContent = ` + # This is a comment + test.txt + + # This is also ignored + *.js + ` + expect(shouldIgnorePath("test.txt", ignoreContent)).toBe(true) + expect(shouldIgnorePath("app.js", ignoreContent)).toBe(true) + }) + + test("negation pattern", () => { + const ignoreContent = ` + *.txt + !important.txt + docs/ + !docs/README.txt + ` + // Matches *.txt but excluded by !important.txt + expect(shouldIgnorePath("test.txt", ignoreContent)).toBe(true) + expect(shouldIgnorePath("important.txt", ignoreContent)).toBe(false) + + // Matches docs/ but excluded by !docs/README.txt + expect(shouldIgnorePath("docs/test.txt", ignoreContent)).toBe(true) + expect(shouldIgnorePath("docs/README.txt", ignoreContent)).toBe(false) + }) + + test("complex negation pattern combinations", () => { + const ignoreContent = ` + # Ignore all .log files + *.log + # But not debug.log + !debug.log + # However, ignore debug.log in tmp/ + tmp/debug.log + ` + expect(shouldIgnorePath("error.log", ignoreContent)).toBe(true) + expect(shouldIgnorePath("debug.log", ignoreContent)).toBe(false) + expect(shouldIgnorePath("tmp/debug.log", ignoreContent)).toBe(true) + }) + + test("negation pattern with reversed order", () => { + const ignoreContent = ` + !.env.example + .env* + ` + // .env.example should be ignored because .env* comes after !.env.example + expect(shouldIgnorePath(".env.example", ignoreContent)).toBe(true) + expect(shouldIgnorePath(".env.local", ignoreContent)).toBe(true) + }) +}) diff --git a/src/utils/cline-ignore.ts b/src/utils/cline-ignore.ts new file mode 100644 index 0000000000..ecb975b984 --- /dev/null +++ b/src/utils/cline-ignore.ts @@ -0,0 +1,87 @@ +import { fileExistsAtPath } from "./fs" +import * as path from "path" +import * as fs from "fs/promises" + +/** + * Loads the contents of .clineignore file and returns cache and evaluation function. + * @param cwd Current working directory + * @returns Object containing patterns and evaluation function + */ +function parseIgnorePatterns(clineIgnoreFile: string): string[] { + return clineIgnoreFile + .split("\n") + .map((line) => line.trim()) + .filter((line) => line && !line.startsWith("#")) +} + +export async function loadIgnorePatterns(cwd: string): Promise<{ + patterns: string[] + shouldIgnore: (path: string) => boolean +}> { + const ignoreContent = await loadClineIgnoreFile(cwd) + const patterns = parseIgnorePatterns(ignoreContent) + return { + patterns, + shouldIgnore: (path: string) => shouldIgnorePath(path, ignoreContent), + } +} + +/** + * Filters multiple file paths in batch. + * @param paths Array of paths to filter + * @param ignoreContent Contents of .clineignore file + * @returns Array of filtered paths + */ +export function filterIgnoredPaths(paths: string[], ignoreContent: string): string[] { + return paths.filter((path) => !shouldIgnorePath(path, ignoreContent)) +} + +export async function loadClineIgnoreFile(cwd: string): Promise { + const filePath = path.join(cwd, ".clineignore") + try { + const fileExists = await fileExistsAtPath(filePath) + if (!fileExists) { + return "" + } + return fs.readFile(filePath, "utf-8") + } catch (error) { + return "" + } +} + +function convertGlobToRegExp(pattern: string): string { + // Handle directory pattern + if (pattern.endsWith("/")) { + pattern = pattern + "**" + } + + return ( + pattern + // Escape special characters + .replace(/[.+^${}()|[\]\\]/g, "\\$&") + // Convert wildcard * to regex pattern + .replace(/\*/g, ".*") + ) +} + +export function shouldIgnorePath(filePath: string, clineIgnoreFile: string): boolean { + const patterns = parseIgnorePatterns(clineIgnoreFile) + let isIgnored = false + + // Evaluate patterns in order + for (const pattern of patterns) { + const isNegation = pattern.startsWith("!") + const actualPattern = isNegation ? pattern.slice(1) : pattern + + // Convert pattern to regex + const regexPattern = convertGlobToRegExp(actualPattern) + const regex = new RegExp(`^${regexPattern}$`) + + // Check if pattern matches + if (regex.test(filePath)) { + isIgnored = !isNegation + } + } + + return isIgnored +}