Skip to content

Commit d74a9b1

Browse files
committed
added new GitIgnoreController to handle nested .gitignore files closes #7921
1 parent 7c4635f commit d74a9b1

File tree

9 files changed

+872
-90
lines changed

9 files changed

+872
-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: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
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+
if (await fileExistsAtPath(gitignorePath)) {
55+
this.gitignoreFiles.push(gitignorePath)
56+
await this.loadGitignoreFile(gitignorePath)
57+
}
58+
}
59+
60+
// Always ignore .gitignore files themselves
61+
this.ignoreInstance.add(".gitignore")
62+
} catch (error) {
63+
console.error("Error discovering .gitignore files:", error)
64+
}
65+
}
66+
67+
/**
68+
* Recursively find all .gitignore files in the directory tree
69+
*/
70+
private async findGitignoreFilesRecursively(dirPath: string): Promise<void> {
71+
try {
72+
// Skip the root directory since we already checked it in discoverAndLoadGitignoreFiles
73+
if (dirPath === this.cwd) {
74+
// Get all subdirectories
75+
const entries = await fs.readdir(dirPath, { withFileTypes: true })
76+
const subdirs = entries
77+
.filter((entry) => entry.isDirectory() && !entry.name.startsWith("."))
78+
.map((entry) => path.join(dirPath, entry.name))
79+
80+
// Recursively search subdirectories
81+
for (const subdir of subdirs) {
82+
await this.findGitignoreFilesRecursively(subdir)
83+
}
84+
} else {
85+
// For subdirectories, check for .gitignore and continue recursively
86+
const gitignorePath = path.join(dirPath, ".gitignore")
87+
88+
// Check if .gitignore exists in current directory
89+
if (await fileExistsAtPath(gitignorePath)) {
90+
this.gitignoreFiles.push(gitignorePath)
91+
}
92+
93+
// Get all subdirectories
94+
const entries = await fs.readdir(dirPath, { withFileTypes: true })
95+
const subdirs = entries
96+
.filter((entry) => entry.isDirectory() && !entry.name.startsWith("."))
97+
.map((entry) => path.join(dirPath, entry.name))
98+
99+
// Recursively search subdirectories
100+
for (const subdir of subdirs) {
101+
await this.findGitignoreFilesRecursively(subdir)
102+
}
103+
}
104+
} catch (error) {
105+
// Skip directories we can't read
106+
console.debug(`Could not read directory ${dirPath}:`, error)
107+
}
108+
}
109+
110+
/**
111+
* Load content from a specific .gitignore file
112+
*/
113+
private async loadGitignoreFile(gitignoreFile: string): Promise<void> {
114+
try {
115+
const content = await fs.readFile(gitignoreFile, "utf8")
116+
this.gitignoreContents.set(gitignoreFile, content)
117+
118+
// Add patterns to ignore instance with proper context
119+
// For nested .gitignore files, we need to adjust patterns relative to the workspace root
120+
const relativeDir = path.relative(this.cwd, path.dirname(gitignoreFile))
121+
122+
if (relativeDir) {
123+
// For nested .gitignore files, prefix patterns with the relative directory
124+
const lines = content.split("\n").filter((line) => line.trim() && !line.startsWith("#"))
125+
const adjustedPatterns = lines.map((pattern) => {
126+
const trimmed = pattern.trim()
127+
if (trimmed.startsWith("/")) {
128+
// Absolute patterns (starting with /) are relative to the .gitignore location
129+
return path.posix.join(relativeDir, trimmed.slice(1))
130+
} else if (trimmed.startsWith("!")) {
131+
// Negation patterns
132+
const negatedPattern = trimmed.slice(1)
133+
if (negatedPattern.startsWith("/")) {
134+
return "!" + path.posix.join(relativeDir, negatedPattern.slice(1))
135+
} else {
136+
return "!" + path.posix.join(relativeDir, "**", negatedPattern)
137+
}
138+
} else {
139+
// Relative patterns apply to the directory and all subdirectories
140+
return path.posix.join(relativeDir, "**", trimmed)
141+
}
142+
})
143+
144+
this.ignoreInstance.add(adjustedPatterns)
145+
} else {
146+
// Root .gitignore file - add patterns as-is (like RooIgnoreController)
147+
this.ignoreInstance.add(content)
148+
}
149+
} catch (error) {
150+
console.warn(`Could not read .gitignore at ${gitignoreFile}:`, error)
151+
}
152+
}
153+
154+
/**
155+
* Set up file watchers for all .gitignore files in the workspace
156+
*/
157+
private setupGitIgnoreWatchers(): void {
158+
// Create a watcher for .gitignore files throughout the workspace
159+
const gitignorePattern = new vscode.RelativePattern(this.cwd, "**/.gitignore")
160+
this.setupFileWatcher(gitignorePattern, () => this.discoverAndLoadGitignoreFiles())
161+
}
162+
163+
/**
164+
* Check if the controller has any patterns loaded
165+
*/
166+
protected hasPatterns(): boolean {
167+
return this.gitignoreFiles.length > 0
168+
}
169+
170+
/**
171+
* Get all discovered .gitignore file paths
172+
* @returns Array of absolute paths to .gitignore files
173+
*/
174+
getGitignoreFiles(): string[] {
175+
return [...this.gitignoreFiles]
176+
}
177+
178+
/**
179+
* Get the content of a specific .gitignore file
180+
* @param gitignoreFile - Absolute path to the .gitignore file
181+
* @returns Content of the file or undefined if not found
182+
*/
183+
getGitignoreContent(gitignoreFile: string): string | undefined {
184+
return this.gitignoreContents.get(gitignoreFile)
185+
}
186+
187+
/**
188+
* Check if any .gitignore files exist in the workspace
189+
* @returns true if at least one .gitignore file exists
190+
*/
191+
hasGitignoreFiles(): boolean {
192+
return this.gitignoreFiles.length > 0
193+
}
194+
195+
/**
196+
* Clean up resources when the controller is no longer needed
197+
*/
198+
override dispose(): void {
199+
super.dispose()
200+
this.gitignoreContents.clear()
201+
this.gitignoreFiles = []
202+
}
203+
}

0 commit comments

Comments
 (0)