From 945d1c8cb28b66732dfaefffc9631af78f83a8a0 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 4 Jul 2026 11:55:05 -0400 Subject: [PATCH] fix(core): bound recursive file watching (#35323) --- packages/core/src/filesystem/ignore.ts | 2 +- packages/core/test/filesystem/ignore.test.ts | 29 ++++++++++++++++++ packages/core/test/filesystem/watcher.test.ts | 30 +++++++++++++++---- packages/web/src/content/docs/config.mdx | 4 +-- 4 files changed, 57 insertions(+), 8 deletions(-) diff --git a/packages/core/src/filesystem/ignore.ts b/packages/core/src/filesystem/ignore.ts index 2f5f52bf25a..4d88eb5e87c 100644 --- a/packages/core/src/filesystem/ignore.ts +++ b/packages/core/src/filesystem/ignore.ts @@ -45,7 +45,7 @@ const FILES = [ "**/.nyc_output/**", ] -export const PATTERNS = [...FILES, ...FOLDERS] +export const PATTERNS = [...FILES, ...FOLDERS, `**/{${Array.from(FOLDERS).join(",")}}/**`] export function match(filepath: string, opts?: { extra?: string[]; whitelist?: string[] }) { for (const pattern of opts?.whitelist || []) { diff --git a/packages/core/test/filesystem/ignore.test.ts b/packages/core/test/filesystem/ignore.test.ts index 87b07eacb96..d734ed16b2d 100644 --- a/packages/core/test/filesystem/ignore.test.ts +++ b/packages/core/test/filesystem/ignore.test.ts @@ -1,5 +1,7 @@ import { expect, test } from "bun:test" import { Ignore } from "@opencode-ai/core/filesystem/ignore" +// @ts-ignore +import { createWrapper } from "@parcel/watcher/wrapper" test("match nested and non-nested", () => { expect(Ignore.match("node_modules/index.js")).toBe(true) @@ -8,3 +10,30 @@ test("match nested and non-nested", () => { expect(Ignore.match("node_modules/bar")).toBe(true) expect(Ignore.match("node_modules/bar/")).toBe(true) }) + +test("parcel patterns ignore built-in folders at any depth", async () => { + let ignoreGlobs: string[] = [] + const watcher = createWrapper({ + subscribe: async ( + _directory: string, + _callback: (...args: unknown[]) => unknown, + options: { ignoreGlobs?: string[] }, + ) => { + ignoreGlobs = options.ignoreGlobs ?? [] + }, + }) + await watcher.subscribe("/tmp/project", () => {}, { ignore: Ignore.PATTERNS }) + const patterns = ignoreGlobs.map((source) => new RegExp(source)) + + for (const path of [ + "nested/node_modules", + "nested/node_modules/package/index.js", + "nested/.git", + "nested/.git/HEAD", + "nested/dist", + "nested/dist/index.js", + ]) { + expect(patterns.some((pattern) => pattern.test(path))).toBe(true) + } + expect(patterns.some((pattern) => pattern.test("nested/src/index.ts"))).toBe(false) +}) diff --git a/packages/core/test/filesystem/watcher.test.ts b/packages/core/test/filesystem/watcher.test.ts index 323ba4c4ba4..360c4c70db2 100644 --- a/packages/core/test/filesystem/watcher.test.ts +++ b/packages/core/test/filesystem/watcher.test.ts @@ -2,7 +2,7 @@ import { $ } from "bun" import { describe, expect } from "bun:test" import fs from "fs/promises" import path from "path" -import { Deferred, Duration, Effect, Fiber, Layer, Option, Stream } from "effect" +import { Deferred, Duration, Effect, Fiber, Layer, Option, Schedule, Stream } from "effect" import { Config } from "@opencode-ai/core/config" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/core/effect/layer-node" @@ -149,12 +149,14 @@ describeWatcher("LocationWatcher", () => { const update = yield* watcher .subscribe({ path: target, type: "file" }) .pipe(Stream.take(1), Stream.runHead, Effect.forkScoped({ startImmediately: true })) - yield* Effect.yieldNow - yield* fs.writeFileString(sibling, "sibling") - yield* fs.writeFileString(target, "target") + const writes = yield* Effect.suspend(() => fs.writeFileString(target, `target-${Math.random()}`)).pipe( + Effect.repeat(Schedule.spaced("10 millis")), + Effect.forkScoped, + ) + const event = yield* Fiber.join(update).pipe(Effect.ensuring(Fiber.interrupt(writes))) - expect((yield* Fiber.join(update)).valueOrUndefined?.path).toBe(target) + expect(event.valueOrUndefined?.path).toBe(target) }).pipe(Effect.provide(AppNodeBuilder.build(Watcher.node))), ), ) @@ -197,6 +199,24 @@ describeWatcher("LocationWatcher", () => { ), ) + it.live("ignores dependency, VCS, and build directories at any depth", () => + withTmp((directory) => + Effect.gen(function* () { + const afs = yield* FSUtil.Service + yield* ready(directory) + const roots = ["node_modules", ".git", "dist"].map((name) => path.join(directory, "nested", name)) + const files = roots.map((root) => path.join(root, "package", "index.js")) + yield* noUpdate( + (event) => roots.some((root) => event.file === root || event.file.startsWith(`${root}${path.sep}`)), + Effect.forEach(files, (file) => afs.writeWithDirs(file, "ignored"), { + concurrency: "unbounded", + discard: true, + }), + ) + }), + ), + ) + it.live("cleanup stops publishing events", () => Effect.gen(function* () { const events = yield* EventV2.Service diff --git a/packages/web/src/content/docs/config.mdx b/packages/web/src/content/docs/config.mdx index c1a69f5a866..fbf41d4fe9e 100644 --- a/packages/web/src/content/docs/config.mdx +++ b/packages/web/src/content/docs/config.mdx @@ -749,12 +749,12 @@ You can configure file watcher ignore patterns through the `watcher` option. { "$schema": "https://opencode.ai/config.json", "watcher": { - "ignore": ["node_modules/**", "dist/**", ".git/**"] + "ignore": ["**/generated/**"] } } ``` -Patterns follow glob syntax. Use this to exclude noisy directories from file watching. +Patterns follow glob syntax. Common dependency, VCS, build, and cache directories are ignored automatically at any depth. ---