Skip to content

Commit ee47c23

Browse files
committed
feat(ignore): port upstream GitIgnoreController/BaseIgnoreController and integrate with RooIgnoreController, Task, searchFilesTool; add tests; ripgrep formatting fix
1 parent bbecb5d commit ee47c23

File tree

10 files changed

+1361
-90
lines changed

10 files changed

+1361
-90
lines changed
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import path from "path"
2+
import fsSync from "fs"
3+
import ignore, { Ignore } from "ignore"
4+
import * as vscode from "vscode"
5+
6+
/**
7+
* Base class for ignore controllers that provides common functionality
8+
* for handling ignore patterns and file validation.
9+
*/
10+
export abstract class BaseIgnoreController {
11+
protected cwd: string
12+
protected ignoreInstance: Ignore
13+
protected disposables: vscode.Disposable[] = []
14+
15+
constructor(cwd: string) {
16+
this.cwd = cwd
17+
this.ignoreInstance = ignore()
18+
}
19+
20+
/**
21+
* Initialize the controller - must be implemented by subclasses
22+
*/
23+
abstract initialize(): Promise<void>
24+
25+
/**
26+
* Check if a file should be accessible (not ignored by patterns)
27+
* Automatically resolves symlinks
28+
* @param filePath - Path to check (relative to cwd)
29+
* @returns true if file is accessible, false if ignored
30+
*/
31+
validateAccess(filePath: string): boolean {
32+
// Allow subclasses to override the "no patterns" check
33+
if (!this.hasPatterns()) {
34+
return true
35+
}
36+
37+
try {
38+
const absolutePath = path.resolve(this.cwd, filePath)
39+
40+
// Follow symlinks to get the real path
41+
let realPath: string
42+
try {
43+
realPath = fsSync.realpathSync(absolutePath)
44+
} catch {
45+
// If realpath fails (file doesn't exist, broken symlink, etc.),
46+
// use the original path
47+
realPath = absolutePath
48+
}
49+
50+
// Convert real path to relative for ignore checking
51+
const relativePath = path.relative(this.cwd, realPath).toPosix()
52+
53+
// Check if the real path is ignored
54+
return !this.ignoreInstance.ignores(relativePath)
55+
} catch (error) {
56+
// Allow access to files outside cwd or on errors (backward compatibility)
57+
return true
58+
}
59+
}
60+
61+
/**
62+
* Filter an array of paths, removing those that should be ignored
63+
* @param paths - Array of paths to filter (relative to cwd)
64+
* @returns Array of allowed paths
65+
*/
66+
filterPaths(paths: string[]): string[] {
67+
try {
68+
return paths
69+
.map((p) => ({
70+
path: p,
71+
allowed: this.validateAccess(p),
72+
}))
73+
.filter((x) => x.allowed)
74+
.map((x) => x.path)
75+
} catch (error) {
76+
console.error("Error filtering paths:", error)
77+
return [] // Fail closed for security
78+
}
79+
}
80+
81+
/**
82+
* Clean up resources when the controller is no longer needed
83+
*/
84+
dispose(): void {
85+
this.disposables.forEach((d) => d.dispose())
86+
this.disposables = []
87+
}
88+
89+
/**
90+
* Check if the controller has any patterns loaded
91+
* Must be implemented by subclasses
92+
*/
93+
protected abstract hasPatterns(): boolean
94+
95+
/**
96+
* Set up file watchers with debouncing to avoid rapid reloads
97+
* @param pattern - VSCode RelativePattern for the files to watch
98+
* @param reloadCallback - Function to call when files change
99+
*/
100+
protected setupFileWatcher(pattern: vscode.RelativePattern, reloadCallback: () => void): void {
101+
const fileWatcher = vscode.workspace.createFileSystemWatcher(pattern)
102+
103+
// Debounce rapid changes
104+
let reloadTimeout: NodeJS.Timeout | undefined
105+
const debouncedReload = () => {
106+
if (reloadTimeout) {
107+
clearTimeout(reloadTimeout)
108+
}
109+
reloadTimeout = setTimeout(reloadCallback, 100)
110+
}
111+
112+
// Watch for changes, creation, and deletion
113+
this.disposables.push(
114+
fileWatcher.onDidChange(debouncedReload),
115+
fileWatcher.onDidCreate(debouncedReload),
116+
fileWatcher.onDidDelete(debouncedReload),
117+
)
118+
119+
// Add fileWatcher itself to disposables
120+
this.disposables.push(fileWatcher)
121+
}
122+
}
Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
import path from "path"
2+
import { fileExistsAtPath } from "../../utils/fs"
3+
import fs from "fs/promises"
4+
import ignore from "ignore"
5+
import * as vscode from "vscode"
6+
import { BaseIgnoreController } from "./BaseIgnoreController"
7+
8+
/**
9+
* Controls file access by enforcing nested .gitignore patterns.
10+
* Handles multiple .gitignore files throughout the directory tree, unlike ripgrep which only honors top-level .gitignore.
11+
* Designed to be instantiated once and passed to file manipulation services.
12+
* Uses the 'ignore' library to support standard .gitignore syntax.
13+
*/
14+
export class GitIgnoreController extends BaseIgnoreController {
15+
private gitignoreFiles: string[] = []
16+
private gitignoreContents: Map<string, string> = new Map()
17+
18+
constructor(cwd: string) {
19+
super(cwd)
20+
this.gitignoreFiles = []
21+
this.gitignoreContents = new Map()
22+
}
23+
24+
/**
25+
* Initialize the controller by discovering and loading all .gitignore files
26+
* Must be called after construction and before using the controller
27+
*/
28+
async initialize(): Promise<void> {
29+
await this.discoverAndLoadGitignoreFiles()
30+
this.setupGitIgnoreWatchers()
31+
}
32+
33+
/**
34+
* Discover and load .gitignore files (root + common subdirectories)
35+
*/
36+
private async discoverAndLoadGitignoreFiles(): Promise<void> {
37+
try {
38+
// Reset state
39+
this.ignoreInstance = ignore()
40+
this.gitignoreFiles = []
41+
this.gitignoreContents.clear()
42+
43+
// Check for common .gitignore file locations (manually defined for simplicity)
44+
const commonGitignorePaths = [
45+
path.join(this.cwd, ".gitignore"), // Root
46+
path.join(this.cwd, "src", ".gitignore"), // src/
47+
path.join(this.cwd, "lib", ".gitignore"), // lib/
48+
path.join(this.cwd, "test", ".gitignore"), // test/
49+
path.join(this.cwd, "tests", ".gitignore"), // tests/
50+
]
51+
52+
// Check each location and load if it exists
53+
for (const gitignorePath of commonGitignorePaths) {
54+
const exists = await fileExistsAtPath(gitignorePath)
55+
56+
if (exists) {
57+
this.gitignoreFiles.push(gitignorePath)
58+
await this.loadGitignoreFile(gitignorePath)
59+
}
60+
}
61+
62+
// Also discover arbitrary nested .gitignore files across the workspace
63+
await this.findGitignoreFilesRecursively(this.cwd)
64+
65+
// Load any files discovered by recursion that weren't loaded yet
66+
for (const p of this.gitignoreFiles) {
67+
if (!this.gitignoreContents.has(p)) {
68+
await this.loadGitignoreFile(p)
69+
}
70+
}
71+
72+
// Always ignore .gitignore files themselves
73+
this.ignoreInstance.add(".gitignore")
74+
} catch (error) {
75+
console.error("Error discovering .gitignore files:", error)
76+
}
77+
}
78+
79+
/**
80+
* Recursively find all .gitignore files in the directory tree
81+
*/
82+
private async findGitignoreFilesRecursively(dirPath: string): Promise<void> {
83+
try {
84+
// Skip the root directory since we already checked it in discoverAndLoadGitignoreFiles
85+
if (dirPath === this.cwd) {
86+
// Get all subdirectories
87+
const entries = await fs.readdir(dirPath, { withFileTypes: true })
88+
const subdirs = entries
89+
.filter((entry) => entry.isDirectory() && !entry.name.startsWith("."))
90+
.map((entry) => path.join(dirPath, entry.name))
91+
92+
// Recursively search subdirectories
93+
for (const subdir of subdirs) {
94+
await this.findGitignoreFilesRecursively(subdir)
95+
}
96+
} else {
97+
// For subdirectories, check for .gitignore and continue recursively
98+
const gitignorePath = path.join(dirPath, ".gitignore")
99+
100+
// Check if .gitignore exists in current directory
101+
if (await fileExistsAtPath(gitignorePath)) {
102+
this.gitignoreFiles.push(gitignorePath)
103+
}
104+
105+
// Get all subdirectories
106+
const entries = await fs.readdir(dirPath, { withFileTypes: true })
107+
const subdirs = entries
108+
.filter((entry) => entry.isDirectory() && !entry.name.startsWith("."))
109+
.map((entry) => path.join(dirPath, entry.name))
110+
111+
// Recursively search subdirectories
112+
for (const subdir of subdirs) {
113+
await this.findGitignoreFilesRecursively(subdir)
114+
}
115+
}
116+
} catch (error) {
117+
// Skip directories we can't read
118+
console.debug(`Could not read directory ${dirPath}:`, error)
119+
}
120+
}
121+
122+
/**
123+
* Load content from a specific .gitignore file
124+
*/
125+
private async loadGitignoreFile(gitignoreFile: string): Promise<void> {
126+
try {
127+
const content = await fs.readFile(gitignoreFile, "utf8")
128+
this.gitignoreContents.set(gitignoreFile, content)
129+
130+
// Add patterns to ignore instance with proper context
131+
// For nested .gitignore files, we need to adjust patterns relative to the workspace root
132+
const relativeDir = path.relative(this.cwd, path.dirname(gitignoreFile))
133+
134+
if (relativeDir) {
135+
// For nested .gitignore files, we need to create patterns that match files within that directory
136+
const lines = content.split(/\r?\n/).filter((line) => line.trim() && !line.startsWith("#"))
137+
// Convert Windows paths to POSIX for consistent pattern matching
138+
const normalizedRelativeDir = relativeDir.split(path.sep).join("/")
139+
140+
const adjustedPatterns = lines.flatMap((pattern) => {
141+
const trimmed = pattern.trim()
142+
143+
if (trimmed.startsWith("/")) {
144+
// Absolute patterns (starting with /) are relative to the .gitignore location
145+
return [normalizedRelativeDir + trimmed]
146+
} else if (trimmed.startsWith("!")) {
147+
// Negation patterns
148+
const negatedPattern = trimmed.slice(1)
149+
if (negatedPattern.startsWith("/")) {
150+
return ["!" + normalizedRelativeDir + negatedPattern]
151+
} else {
152+
// For relative negation patterns, match in the directory and subdirectories
153+
return [
154+
"!" + normalizedRelativeDir + "/" + negatedPattern,
155+
"!" + normalizedRelativeDir + "/**/" + negatedPattern,
156+
]
157+
}
158+
} else {
159+
// Relative patterns - match files in the directory and all subdirectories
160+
// For "*.tmp" in src/.gitignore, we need TWO patterns:
161+
// - src/*.tmp (matches direct children like src/temp.tmp)
162+
// - src/**/*.tmp (matches descendants like src/subdir/temp.tmp)
163+
const patterns = [
164+
normalizedRelativeDir + "/" + trimmed,
165+
normalizedRelativeDir + "/**/" + trimmed,
166+
]
167+
return patterns
168+
}
169+
})
170+
171+
this.ignoreInstance.add(adjustedPatterns)
172+
} else {
173+
// Root .gitignore file - add patterns as-is (like RooIgnoreController)
174+
this.ignoreInstance.add(content)
175+
}
176+
} catch (error) {
177+
console.warn(`Could not read .gitignore at ${gitignoreFile}:`, error)
178+
}
179+
}
180+
181+
/**
182+
* Set up file watchers for all .gitignore files in the workspace
183+
*/
184+
private setupGitIgnoreWatchers(): void {
185+
// Create a watcher for .gitignore files throughout the workspace
186+
const gitignorePattern = new vscode.RelativePattern(this.cwd, "**/.gitignore")
187+
this.setupFileWatcher(gitignorePattern, () => this.discoverAndLoadGitignoreFiles())
188+
}
189+
190+
/**
191+
* Check if the controller has any patterns loaded
192+
*/
193+
protected hasPatterns(): boolean {
194+
return this.gitignoreFiles.length > 0
195+
}
196+
197+
/**
198+
* Get all discovered .gitignore file paths
199+
* @returns Array of absolute paths to .gitignore files
200+
*/
201+
getGitignoreFiles(): string[] {
202+
return [...this.gitignoreFiles]
203+
}
204+
205+
/**
206+
* Get the content of a specific .gitignore file
207+
* @param gitignoreFile - Absolute path to the .gitignore file
208+
* @returns Content of the file or undefined if not found
209+
*/
210+
getGitignoreContent(gitignoreFile: string): string | undefined {
211+
return this.gitignoreContents.get(gitignoreFile)
212+
}
213+
214+
/**
215+
* Check if any .gitignore files exist in the workspace
216+
* @returns true if at least one .gitignore file exists
217+
*/
218+
hasGitignoreFiles(): boolean {
219+
return this.gitignoreFiles.length > 0
220+
}
221+
222+
/**
223+
* Clean up resources when the controller is no longer needed
224+
*/
225+
override dispose(): void {
226+
super.dispose()
227+
this.gitignoreContents.clear()
228+
this.gitignoreFiles = []
229+
}
230+
}

0 commit comments

Comments
 (0)