diff --git a/packages/core/src/filesystem/search.ts b/packages/core/src/filesystem/search.ts index c7738388bcc0..550c4c9cd26a 100644 --- a/packages/core/src/filesystem/search.ts +++ b/packages/core/src/filesystem/search.ts @@ -232,7 +232,16 @@ export const fffLayer = Layer.effect( }), ) -const layer = Layer.unwrap(Effect.sync(() => (Flag.OPENCODE_DISABLE_FFF || !Fff.available() ? ripgrepLayer : fffLayer))) +// fff has no ignore-file support, so repos governed by a .boltignore must use +// the ripgrep backend to keep hidden files out of glob/find/grep results. +const layer = Layer.unwrap( + Effect.gen(function* () { + if (Flag.OPENCODE_DISABLE_FFF || !Fff.available()) return ripgrepLayer + const location = yield* Location.Service + if (Ripgrep.ignored(location.directory)) return ripgrepLayer + return fffLayer + }), +) export const locationLayer = layer diff --git a/packages/core/src/ripgrep.ts b/packages/core/src/ripgrep.ts index 8da58b178c12..03b42aee54d3 100644 --- a/packages/core/src/ripgrep.ts +++ b/packages/core/src/ripgrep.ts @@ -29,26 +29,34 @@ const MAX_SUBMATCHES = 100 // directory) to keep root-anchored rules like `/src/generated/` correctly // scoped. User globs and output paths are translated between the two bases. function boltignore(cwd: string) { - const root = (() => { - let dir = cwd - while (true) { - if (fs.existsSync(path.join(dir, ".git"))) return dir - const parent = path.dirname(dir) - if (parent === dir) return cwd - dir = parent - } - })() - const file = path.join(root, ".boltignore") - if (!fs.existsSync(file)) return { cwd, args: [], search: ".", prefix: "" } - const search = path.relative(root, cwd) + const base = root(cwd) + const file = path.join(base, ".boltignore") + if (!fs.statSync(file, { throwIfNoEntry: false })?.isFile()) return { cwd, args: [], search: ".", prefix: "" } + const search = path.relative(base, cwd) return { - cwd: root, + cwd: base, args: [`--ignore-file=${file}`], search: search || ".", prefix: search ? `${search.replaceAll("\\", "/")}/` : "", } } +function root(cwd: string) { + let dir = cwd + while (true) { + if (fs.existsSync(path.join(dir, ".git"))) return dir + const parent = path.dirname(dir) + if (parent === dir) return cwd + dir = parent + } +} + +// Whether a .boltignore governs this directory. Search backends without +// ignore-file support (fff) must fall back to ripgrep when this is true. +export function ignored(cwd: string) { + return fs.statSync(path.join(root(cwd), ".boltignore"), { throwIfNoEntry: false })?.isFile() === true +} + // -g globs follow .gitignore rules relative to the ripgrep working directory: // slash-less globs match basenames at any depth and survive re-basing, while // globs with a non-trailing slash are anchored and must be re-anchored onto diff --git a/packages/core/test/ripgrep.test.ts b/packages/core/test/ripgrep.test.ts index d8cdbbc61149..ed4f83be537c 100644 --- a/packages/core/test/ripgrep.test.ts +++ b/packages/core/test/ripgrep.test.ts @@ -69,6 +69,24 @@ describe("Ripgrep", () => { ), ) + it.live("ignored reports whether a .boltignore governs a directory", () => + Effect.acquireUseRelease( + Effect.promise(() => tmpdir()), + (tmp) => + Effect.gen(function* () { + yield* Effect.promise(() => fs.mkdir(path.join(tmp.path, "src"), { recursive: true })) + yield* Effect.promise(() => Bun.$`git init -q ${tmp.path}`) + expect(Ripgrep.ignored(tmp.path)).toBe(false) + expect(Ripgrep.ignored(path.join(tmp.path, "src"))).toBe(false) + yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, ".boltignore"), "hidden.txt\n")) + expect(Ripgrep.ignored(tmp.path)).toBe(true) + // Subdirectories resolve the .boltignore at the repository root. + expect(Ripgrep.ignored(path.join(tmp.path, "src"))).toBe(true) + }), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ), + ) + it.live("never includes git metadata", () => Effect.acquireUseRelease( Effect.promise(() => tmpdir()),