mirror of
https://github.com/stablyai/orca.git
synced 2026-10-04 00:02:21 +00:00
fix(files): ignore nested generated directories on macOS (#24620)
* fix(files): ignore nested generated directories on macOS * fix(files): filter generated filenames containing newlines
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { subscribe, type AsyncSubscription, type Event } from '@parcel/watcher'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { WATCHER_IGNORE_DIRS, buildParcelWatcherIgnoreOptions } from './filesystem-watcher-ignore'
|
||||
|
||||
describe('filesystem watcher native ignores', () => {
|
||||
let root: string | null = null
|
||||
let subscription: AsyncSubscription | null = null
|
||||
|
||||
afterEach(async () => {
|
||||
await subscription?.unsubscribe()
|
||||
subscription = null
|
||||
if (root) {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
root = null
|
||||
}
|
||||
})
|
||||
|
||||
it('drops root and nested generated-file storms while delivering source edits', async () => {
|
||||
root = await realpath(await mkdtemp(join(tmpdir(), 'orca-watch-ignore-')))
|
||||
const rootModules = join(root, 'node_modules')
|
||||
const nestedModules = join(root, 'packages', 'app', 'node_modules')
|
||||
const nestedSource = join(root, 'packages', 'app', 'src')
|
||||
await Promise.all(
|
||||
[rootModules, nestedModules, nestedSource].map((directory) =>
|
||||
mkdir(directory, { recursive: true })
|
||||
)
|
||||
)
|
||||
|
||||
const events: Event[] = []
|
||||
const errors: Error[] = []
|
||||
subscription = await subscribe(
|
||||
root,
|
||||
(error, batch) => {
|
||||
if (error) {
|
||||
errors.push(error)
|
||||
}
|
||||
events.push(...batch)
|
||||
},
|
||||
buildParcelWatcherIgnoreOptions(WATCHER_IGNORE_DIRS)
|
||||
)
|
||||
|
||||
const generatedNames = Array.from({ length: 20 }, (_, index) => `generated-${index}.js`)
|
||||
// Windows forbids control characters in filenames.
|
||||
if (process.platform !== 'win32') {
|
||||
generatedNames.push('generated\nnewline.js')
|
||||
}
|
||||
const generatedFiles = [rootModules, nestedModules].flatMap((directory) =>
|
||||
generatedNames.map((name) => join(directory, name))
|
||||
)
|
||||
await Promise.all(generatedFiles.map((file) => writeFile(file, 'generated')))
|
||||
const sourceFiles = [join(root, 'source.ts'), join(nestedSource, 'source.ts')]
|
||||
if (process.platform !== 'win32') {
|
||||
sourceFiles.push(join(root, 'source\nnewline.ts'), join(nestedSource, 'source\nnewline.ts'))
|
||||
}
|
||||
await Promise.all(sourceFiles.map((file) => writeFile(file, 'source')))
|
||||
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
for (const sourceFile of sourceFiles) {
|
||||
expect(events.some((event) => event.path === sourceFile)).toBe(true)
|
||||
}
|
||||
},
|
||||
{ timeout: 8_000 }
|
||||
)
|
||||
// Why: ignored callbacks must stay absent after the native debounce has drained.
|
||||
await new Promise((resolve) => setTimeout(resolve, 300))
|
||||
expect(errors).toEqual([])
|
||||
const generatedPaths = new Set(generatedFiles)
|
||||
expect(events.filter((event) => generatedPaths.has(event.path))).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -27,12 +27,50 @@ describe('buildParcelWatcherIgnoreOptions', () => {
|
||||
expect(plainPaths.length).toBeLessThanOrEqual(MACOS_FSEVENTS_EXCLUSION_PATH_LIMIT)
|
||||
expect(option).toEqual(WATCHER_IGNORE_DIRS.slice(0, MACOS_FSEVENTS_EXCLUSION_PATH_LIMIT))
|
||||
const fallbackRegex = new RegExp(options.ignoreGlobs?.[0] ?? '(?!)')
|
||||
for (const dir of WATCHER_IGNORE_DIRS.slice(MACOS_FSEVENTS_EXCLUSION_PATH_LIMIT)) {
|
||||
for (const dir of WATCHER_IGNORE_DIRS) {
|
||||
expect(fallbackRegex.test(dir)).toBe(true)
|
||||
expect(fallbackRegex.test(`${dir}/file.ts`)).toBe(true)
|
||||
expect(fallbackRegex.test(`packages/app/${dir}`)).toBe(true)
|
||||
expect(fallbackRegex.test(`packages/app/${dir}/file.ts`)).toBe(true)
|
||||
}
|
||||
expect(fallbackRegex.test('packages/app/node_modules-cache/file.ts')).toBe(false)
|
||||
expect(fallbackRegex.test('packages/app/.github/workflows/check.yml')).toBe(false)
|
||||
expect(fallbackRegex.test('project\\node_modules/file.ts')).toBe(false)
|
||||
expect(options.ignoreGlobs?.[0]).not.toContain('?!')
|
||||
})
|
||||
|
||||
it('filters nested macOS directories when all names fit in daemon exclusions', () => {
|
||||
setPlatform('darwin')
|
||||
const options = buildParcelWatcherIgnoreOptions(['node_modules', '.cache'])
|
||||
expect(options.ignore).toEqual(['node_modules', '.cache'])
|
||||
const regex = new RegExp(options.ignoreGlobs?.[0] ?? '(?!)')
|
||||
expect(regex.test('packages/app/node_modules/file.js')).toBe(true)
|
||||
expect(regex.test('packages/app/.cache/file.js')).toBe(true)
|
||||
expect(regex.test('packages/app/xcache/file.js')).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps an empty ignore list empty', () => {
|
||||
for (const platform of ['darwin', 'linux', 'win32'] as const) {
|
||||
setPlatform(platform)
|
||||
expect(buildParcelWatcherIgnoreOptions([])).toEqual({})
|
||||
}
|
||||
})
|
||||
|
||||
it('matches newline-containing paths only under ignored names', () => {
|
||||
for (const platform of ['darwin', 'linux', 'win32'] as const) {
|
||||
setPlatform(platform)
|
||||
const options = buildParcelWatcherIgnoreOptions(WATCHER_IGNORE_DIRS)
|
||||
const regex = new RegExp(options.ignoreGlobs?.[0] ?? '(?!)')
|
||||
expect(regex.test('packages/app/node_modules/generated\nnewline.js')).toBe(true)
|
||||
expect(regex.test('packages/app/src/source\nnewline.ts')).toBe(false)
|
||||
expect(regex.test('packages/app/node_modules-cache/generated\nnewline.js')).toBe(false)
|
||||
expect(regex.test('packages\\app\\node_modules\\generated\nnewline.js')).toBe(
|
||||
platform === 'win32'
|
||||
)
|
||||
expect(regex.test('packages\\app\\src\\source\nnewline.ts')).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('uses one lookahead-free native regex for nested ignores on Linux/Windows', () => {
|
||||
for (const platform of ['linux', 'win32'] as const) {
|
||||
setPlatform(platform)
|
||||
|
||||
@@ -18,9 +18,7 @@ export const WATCHER_IGNORE_DIRS: string[] = [
|
||||
// closed — one entry over the cap and @parcel/watcher silently loses ALL
|
||||
// daemon-side exclusions, so fseventsd delivers every node_modules/.git event
|
||||
// to this process (measured ~29x client CPU plus daemon-side delivery load).
|
||||
// Keep the 8 highest-churn dirs as plain paths (daemon-excluded) and demote
|
||||
// the rest to globs (userspace-filtered). Ordering of WATCHER_IGNORE_DIRS is
|
||||
// therefore meaningful: the first 8 get true daemon-side exclusion on macOS.
|
||||
// The first 8 names get root-level daemon exclusions; the regex covers all names at every depth.
|
||||
export const MACOS_FSEVENTS_EXCLUSION_PATH_LIMIT = 8
|
||||
|
||||
export type ParcelWatcherIgnoreOptions = {
|
||||
@@ -37,10 +35,10 @@ function escapeRegex(value: string): string {
|
||||
function buildNestedDirectoryRegex(ignoreDirs: readonly string[]): string {
|
||||
const alternatives = ignoreDirs.map(escapeRegex).join('|')
|
||||
if (process.platform === 'win32') {
|
||||
return `^(?:[^\\\\/]+[\\\\/])*(?:${alternatives})(?:[\\\\/].*)?$`
|
||||
return `^(?:[^\\\\/]+[\\\\/])*(?:${alternatives})(?:[\\\\/][\\s\\S]*)?$`
|
||||
}
|
||||
// Why: backslash is a legal POSIX filename character, not a path separator.
|
||||
return `^(?:[^/]+/)*(?:${alternatives})(?:/.*)?$`
|
||||
return `^(?:[^/]+/)*(?:${alternatives})(?:/[\\s\\S]*)?$`
|
||||
}
|
||||
|
||||
export function buildParcelWatcherIgnoreOptions(
|
||||
@@ -56,9 +54,9 @@ export function buildParcelWatcherIgnoreOptions(
|
||||
return { ignoreGlobs: [buildNestedDirectoryRegex(ignoreDirs)] }
|
||||
}
|
||||
const daemonExcludedDirs = ignoreDirs.slice(0, MACOS_FSEVENTS_EXCLUSION_PATH_LIMIT)
|
||||
const remainingDirs = ignoreDirs.slice(MACOS_FSEVENTS_EXCLUSION_PATH_LIMIT)
|
||||
return {
|
||||
ignore: [...daemonExcludedDirs],
|
||||
...(remainingDirs.length > 0 ? { ignoreGlobs: [buildNestedDirectoryRegex(remainingDirs)] } : {})
|
||||
// Why: plain exclusions resolve under the root, leaving nested generated directories unfiltered.
|
||||
ignoreGlobs: [buildNestedDirectoryRegex(ignoreDirs)]
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user