diff --git a/src/core/Cline.ts b/src/core/Cline.ts index c9c3e3c066..0d031b13b0 100644 --- a/src/core/Cline.ts +++ b/src/core/Cline.ts @@ -45,7 +45,7 @@ import { getApiMetrics } from "../shared/getApiMetrics" import { HistoryItem } from "../shared/HistoryItem" import { ClineAskResponse, ClineCheckpointRestore } from "../shared/WebviewMessage" import { calculateApiCost } from "../utils/cost" -import { fileExistsAtPath } from "../utils/fs" +import { checkClineIgnoreFile, fileExistsAtPath } from "../utils/fs" import { arePathsEqual, getReadablePath } from "../utils/path" import { fixModelHtmlEscaping, removeInvalidChars } from "../utils/string" import { AssistantMessageContent, parseAssistantMessage, ToolParamName, ToolUseName } from "./assistant-message" @@ -1587,6 +1587,9 @@ export class Cline { } try { + // Check if the file is protected by .clineignore + checkClineIgnoreFile(relPath) + // Construct newContent from diff let newContent: string if (diff) { @@ -1624,6 +1627,9 @@ export class Cline { break } } else if (content) { + // Check if the file is protected by .clineignore + checkClineIgnoreFile(relPath) + newContent = content // pre-processing newContent for cases where weaker models might add artifacts like markdown codeblock markers (deepseek/llama) or extra escape characters (gemini) diff --git a/src/integrations/misc/extract-text.ts b/src/integrations/misc/extract-text.ts index 67a580af9b..ee3869567a 100644 --- a/src/integrations/misc/extract-text.ts +++ b/src/integrations/misc/extract-text.ts @@ -4,8 +4,19 @@ import pdf from "pdf-parse/lib/pdf-parse" import mammoth from "mammoth" import fs from "fs/promises" import { isBinaryFile } from "isbinaryfile" +import { loadIgnorePatterns } from "../../utils/cline-ignore" export async function extractTextFromFile(filePath: string): Promise { + // Convert file path to relative path + const cwd = path.dirname(filePath) + const relativePath = path.relative(cwd, filePath) + + // Load and check .clineignore patterns + const { shouldIgnore } = await loadIgnorePatterns(cwd) + if (shouldIgnore(relativePath)) { + throw new Error(`File is ignored by .clineignore: ${filePath}`) + } + try { await fs.access(filePath) } catch (error) { diff --git a/src/services/glob/list-files.ts b/src/services/glob/list-files.ts index 8578b914d7..7cf1bd7b08 100644 --- a/src/services/glob/list-files.ts +++ b/src/services/glob/list-files.ts @@ -2,6 +2,7 @@ import { globby, Options } from "globby" import os from "os" import * as path from "path" import { arePathsEqual } from "../../utils/path" +import { loadIgnorePatterns } from "../../utils/cline-ignore" export async function listFiles(dirPath: string, recursive: boolean, limit: number): Promise<[string[], boolean]> { const absolutePath = path.resolve(dirPath) @@ -17,24 +18,29 @@ export async function listFiles(dirPath: string, recursive: boolean, limit: numb return [[homeDir], false] } - const dirsToIgnore = [ - "node_modules", - "__pycache__", - "env", - "venv", - "target/dependency", - "build/dependencies", - "dist", - "out", - "bundle", - "vendor", - "tmp", - "temp", - "deps", - "pkg", - "Pods", - ".*", // '!**/.*' excludes hidden directories, while '!**/.*/**' excludes only their contents. This way we are at least aware of the existence of hidden directories. - ].map((dir) => `**/${dir}/**`) + const { patterns } = await loadIgnorePatterns(absolutePath) + + const dirsToIgnore = + patterns.length > 0 + ? patterns + : [ + "node_modules", + "__pycache__", + "env", + "venv", + "target/dependency", + "build/dependencies", + "dist", + "out", + "bundle", + "vendor", + "tmp", + "temp", + "deps", + "pkg", + "Pods", + ".*", // '!**/.*' excludes hidden directories, while '!**/.*/**' excludes only their contents. This way we are at least aware of the existence of hidden directories. + ].map((dir) => `**/${dir}/**`) const options = { cwd: dirPath, diff --git a/src/services/ripgrep/index.ts b/src/services/ripgrep/index.ts index b9ebe68c77..4329dd0ac3 100644 --- a/src/services/ripgrep/index.ts +++ b/src/services/ripgrep/index.ts @@ -3,6 +3,7 @@ import * as childProcess from "child_process" import * as path from "path" import * as fs from "fs" import * as readline from "readline" +import { loadClineIgnoreFile, loadIgnorePatterns } from "../../utils/cline-ignore" /* This file provides functionality to perform regex searches on files using ripgrep. @@ -173,8 +174,9 @@ export async function regexSearchFiles(cwd: string, directoryPath: string, regex if (currentResult) { results.push(currentResult as SearchResult) } - - return formatResults(results, cwd) + const { shouldIgnore } = await loadIgnorePatterns(cwd) + const filteredResults = results.filter((result) => !shouldIgnore(result.file.replace(`${cwd}/`, ""))) + return formatResults(filteredResults, cwd) } function formatResults(results: SearchResult[], cwd: string): string { diff --git a/src/utils/cline-ignore.test.ts b/src/utils/cline-ignore.test.ts new file mode 100644 index 0000000000..0e80b0aa88 --- /dev/null +++ b/src/utils/cline-ignore.test.ts @@ -0,0 +1,75 @@ +import { describe, it } from "mocha" +import "should" +import { shouldIgnorePath } from "./cline-ignore" + +describe("shouldIgnorePath", () => { + it("exact match pattern", () => { + const ignoreContent = "test.txt" + shouldIgnorePath("test.txt", ignoreContent).should.be.true() + shouldIgnorePath("other.txt", ignoreContent).should.be.false() + }) + + it("wildcard pattern", () => { + const ignoreContent = "*.txt" + shouldIgnorePath("test.txt", ignoreContent).should.be.true() + shouldIgnorePath("test.js", ignoreContent).should.be.false() + }) + + it("directory pattern", () => { + const ignoreContent = "node_modules/" + shouldIgnorePath("node_modules/package.json", ignoreContent).should.be.true() + shouldIgnorePath("src/node_modules.ts", ignoreContent).should.be.false() + }) + + it("comments and empty lines", () => { + const ignoreContent = ` + # This is a comment + test.txt + + # This is also ignored + *.js + ` + shouldIgnorePath("test.txt", ignoreContent).should.be.true() + shouldIgnorePath("app.js", ignoreContent).should.be.true() + }) + + it("negation pattern", () => { + const ignoreContent = ` + *.txt + !important.txt + docs/ + !docs/README.txt + ` + // Matches *.txt but excluded by !important.txt + shouldIgnorePath("test.txt", ignoreContent).should.be.true() + shouldIgnorePath("important.txt", ignoreContent).should.be.false() + + // Matches docs/ but excluded by !docs/README.txt + shouldIgnorePath("docs/test.txt", ignoreContent).should.be.true() + shouldIgnorePath("docs/README.txt", ignoreContent).should.be.false() + }) + + it("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 + ` + shouldIgnorePath("error.log", ignoreContent).should.be.true() + shouldIgnorePath("debug.log", ignoreContent).should.be.false() + shouldIgnorePath("tmp/debug.log", ignoreContent).should.be.true() + }) + + it("negation pattern with reversed order", () => { + const ignoreContent = ` + !.env.example + .env* + ` + // .env.example should be ignored because .env* comes after !.env.example + shouldIgnorePath(".env.example", ignoreContent).should.be.true() + shouldIgnorePath(".env.local", ignoreContent).should.be.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 +} diff --git a/src/utils/fs.ts b/src/utils/fs.ts index 9f7af84e4a..2ed41251e0 100644 --- a/src/utils/fs.ts +++ b/src/utils/fs.ts @@ -45,3 +45,14 @@ export async function fileExistsAtPath(filePath: string): Promise { return false } } + +/** + * Checks if a file is .clineignore and throws an error if it is + * @param filePath - The path of the file to check + * @throws Error if the file is .clineignore + */ +export function checkClineIgnoreFile(filePath: string): void { + if (path.basename(filePath) === ".clineignore") { + throw new Error("Cannot modify '.clineignore' file as it is protected from modifications.") + } +}