Skip to content

Commit dd62a55

Browse files
authored
Merge pull request #6 from RooCodeInc/fix/issue-7921-search_files-includes-files-ignored-by-nested-gitignore
test(ripgrep): add integration test for regexSearchFiles to enforce n…
2 parents 87c0b5d + 59193a1 commit dd62a55

File tree

1 file changed

+214
-0
lines changed

1 file changed

+214
-0
lines changed
Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
import { describe, it, expect, beforeEach, vi, type Mock } from "vitest"
2+
import path from "path"
3+
4+
// Under test
5+
import * as ripgrepMod from "../index"
6+
import { regexSearchFiles } from "../index"
7+
import { GitIgnoreController } from "../../../core/ignore/GitIgnoreController"
8+
9+
// Mocks
10+
import * as fsPromises from "fs/promises"
11+
import type { Dirent } from "fs"
12+
import * as fileUtils from "../../../utils/fs"
13+
14+
// Mock vscode (env + watchers used by controllers)
15+
vi.mock("vscode", () => {
16+
const mockDisposable = { dispose: vi.fn() }
17+
return {
18+
env: { appRoot: "/fake/vscode" },
19+
workspace: {
20+
createFileSystemWatcher: vi.fn(() => ({
21+
onDidCreate: vi.fn(() => mockDisposable),
22+
onDidChange: vi.fn(() => mockDisposable),
23+
onDidDelete: vi.fn(() => mockDisposable),
24+
dispose: vi.fn(),
25+
})),
26+
},
27+
RelativePattern: vi.fn().mockImplementation((base: string, pattern: string) => ({ base, pattern })),
28+
}
29+
})
30+
31+
// Mock child_process.spawn to simulate ripgrep JSON line output
32+
vi.mock("child_process", () => {
33+
const { PassThrough } = require("stream")
34+
const { EventEmitter } = require("events")
35+
36+
return {
37+
spawn: (_bin: string, _args: string[]) => {
38+
const proc = new EventEmitter()
39+
const stdout = new PassThrough()
40+
const stderr = new PassThrough()
41+
// Expose stdout/stderr streams
42+
;(proc as any).stdout = stdout
43+
;(proc as any).stderr = stderr
44+
;(proc as any).kill = vi.fn(() => {
45+
stdout.end()
46+
stderr.end()
47+
})
48+
49+
// Defer writing until next tick to simulate async process output
50+
setImmediate(() => {
51+
const lines: string[] = (globalThis as any).__RG_LINES__ ?? []
52+
for (const ln of lines) {
53+
stdout.write(ln + "\n")
54+
}
55+
stdout.end()
56+
})
57+
58+
return proc
59+
},
60+
}
61+
})
62+
63+
// Ensure fs/promises and file utils are mockable from tests
64+
// Provide explicit mock factory so readdir/readFile are defined vi.fn()
65+
vi.mock("fs/promises", () => ({
66+
readdir: vi.fn(),
67+
readFile: vi.fn(),
68+
}))
69+
vi.mock("../../../utils/fs")
70+
// Mock fs so BaseIgnoreController's realpathSync won't touch the real filesystem
71+
vi.mock("fs", () => ({
72+
realpathSync: vi.fn((filePath: any) => filePath.toString()),
73+
}))
74+
75+
describe("regexSearchFiles + GitIgnoreController integration (nested .gitignore filtering)", () => {
76+
const REPO = "/tmp/repo" // test workspace root
77+
let mockReaddir: Mock<typeof fsPromises.readdir>
78+
let mockReadFile: Mock<typeof fsPromises.readFile>
79+
let mockFileExists: Mock<typeof fileUtils.fileExistsAtPath>
80+
81+
beforeEach(() => {
82+
vi.clearAllMocks()
83+
84+
// Obtain mocked fs/promises fns from mock factory
85+
const anyFs = fsPromises as any
86+
mockReaddir = anyFs.readdir as unknown as Mock<typeof fsPromises.readdir>
87+
mockReadFile = anyFs.readFile as unknown as Mock<typeof fsPromises.readFile>
88+
89+
mockFileExists = fileUtils.fileExistsAtPath as unknown as Mock<typeof fileUtils.fileExistsAtPath>
90+
91+
// Provide a fake ripgrep path so getBinPath succeeds regardless of VSCode layout
92+
vi.spyOn(ripgrepMod, "getBinPath").mockResolvedValue("/fake/rg")
93+
94+
// realpathSync handled by vi.mock("fs") factory above
95+
96+
// Default: no files exist
97+
mockFileExists.mockResolvedValue(false)
98+
99+
// Default dirents helper
100+
const dirent = (name: string, isDir: boolean): Dirent =>
101+
({
102+
name,
103+
isDirectory: () => isDir,
104+
isFile: () => !isDir,
105+
isSymbolicLink: () => false,
106+
}) as unknown as Dirent
107+
// Default readdir: empty
108+
mockReaddir.mockImplementation(async (_p: any, _opts?: any) => {
109+
return [] as any
110+
})
111+
112+
// Default readFile: empty
113+
mockReadFile.mockResolvedValue("")
114+
})
115+
116+
it("excludes matches from files ignored by nested src/.gitignore patterns while keeping allowed files", async () => {
117+
// Arrange a nested .gitignore structure:
118+
// REPO/
119+
// src/.gitignore => '*.tmp' (ignore), '!keep.tmp' (negation)
120+
// src/ignored.tmp (should be filtered)
121+
// src/keep.tmp (should be kept due to negation)
122+
// README.md (not under src, unaffected)
123+
//
124+
// GitIgnoreController recursively discovers src/.gitignore and adjusts patterns relative to REPO.
125+
126+
// File existence for .gitignore files AND ripgrep binary resolution
127+
mockFileExists.mockImplementation(async (p: string) => {
128+
// Make getBinPath succeed by faking rg binary under VSCode appRoot
129+
const binName = process.platform.startsWith("win") ? "rg.exe" : "rg"
130+
const rgCandidate = path.join("/fake/vscode", "node_modules/@vscode/ripgrep/bin/", binName)
131+
if (p === rgCandidate) return true
132+
133+
if (p === path.join(REPO, "src", ".gitignore")) return true
134+
// root .gitignore does not exist for this test
135+
if (p === path.join(REPO, ".gitignore")) return false
136+
return false
137+
})
138+
139+
// Directory tree: REPO has 'src' subdir
140+
const dirent = (name: string, isDir: boolean): Dirent =>
141+
({
142+
name,
143+
isDirectory: () => isDir,
144+
isFile: () => !isDir,
145+
isSymbolicLink: () => false,
146+
}) as unknown as Dirent
147+
148+
mockReaddir.mockImplementation(async (p: any, _opts?: any) => {
149+
if (p === REPO) {
150+
return [dirent("src", true)] as any
151+
}
152+
if (p === path.join(REPO, "src")) {
153+
// No further subdirectories required for this test
154+
return [] as any
155+
}
156+
return [] as any
157+
})
158+
159+
// src/.gitignore content
160+
mockReadFile.mockImplementation(async (p: any, _enc?: any) => {
161+
if (p === path.join(REPO, "src", ".gitignore")) {
162+
return "*.tmp\n!keep.tmp\n"
163+
}
164+
return ""
165+
})
166+
167+
// Prepare ripgrep JSON lines for three files: ignored.tmp, keep.tmp, README.md
168+
const rgLines = [
169+
// src/ignored.tmp
170+
JSON.stringify({ type: "begin", data: { path: { text: "src/ignored.tmp" } } }),
171+
JSON.stringify({
172+
type: "match",
173+
data: { line_number: 1, lines: { text: "foo" }, absolute_offset: 1 },
174+
}),
175+
JSON.stringify({ type: "end", data: {} }),
176+
177+
// src/keep.tmp
178+
JSON.stringify({ type: "begin", data: { path: { text: "src/keep.tmp" } } }),
179+
JSON.stringify({
180+
type: "match",
181+
data: { line_number: 2, lines: { text: "foo" }, absolute_offset: 10 },
182+
}),
183+
JSON.stringify({ type: "end", data: {} }),
184+
185+
// README.md (outside src, unaffected)
186+
JSON.stringify({ type: "begin", data: { path: { text: "README.md" } } }),
187+
JSON.stringify({
188+
type: "match",
189+
data: { line_number: 3, lines: { text: "foo" }, absolute_offset: 20 },
190+
}),
191+
JSON.stringify({ type: "end", data: {} }),
192+
]
193+
;(globalThis as any).__RG_LINES__ = rgLines
194+
195+
// Initialize controller with nested .gitignore
196+
const git = new GitIgnoreController(REPO)
197+
await git.initialize()
198+
// Sanity-check controller behavior before invoking ripgrep filter
199+
expect(git.hasGitignoreFiles()).toBe(true)
200+
expect(git.validateAccess("src/ignored.tmp")).toBe(false)
201+
expect(git.validateAccess("src/keep.tmp")).toBe(true)
202+
203+
// Act
204+
const out = await regexSearchFiles(REPO, REPO, "foo", "*", undefined, git)
205+
206+
// Assert: filtered summary and per-file sections
207+
// - src/ignored.tmp must be filtered out
208+
// - src/keep.tmp must be present (negation)
209+
// - README.md must be present
210+
expect(out).not.toContain("# src/ignored.tmp")
211+
expect(out).toContain("# src/keep.tmp")
212+
expect(out).toContain("# README.md")
213+
})
214+
})

0 commit comments

Comments
 (0)