Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions packages/cli-kit/src/public/node/fs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
copyDirectoryContents,
symlink,
fileRealPath,
matchGlob,
} from './fs.js'
import {joinPath, normalizePath} from './path.js'
import * as array from '../common/array.js'
Expand Down Expand Up @@ -565,3 +566,28 @@ describe('symlink', () => {
})
})
})

describe('matchGlob', () => {
test('matches a key against a glob pattern', () => {
expect(matchGlob('templates/index.json', 'templates/*.json')).toBe(true)
expect(matchGlob('assets/base.css', 'templates/*.json')).toBe(false)
})

test('returns consistent results when the same pattern is reused', () => {
// Compiled matchers are cached by pattern, so reuse must not leak state between keys.
expect(matchGlob('templates/index.json', 'templates/*.json')).toBe(true)
expect(matchGlob('assets/base.css', 'templates/*.json')).toBe(false)
expect(matchGlob('templates/index.json', 'templates/*.json')).toBe(true)
})

test('caches matchers per set of options', () => {
const options = {matchBase: true, noglobstar: true}

expect(matchGlob('sections/header.liquid', '*.liquid', options)).toBe(true)
expect(matchGlob('sections/header.liquid', '*.liquid')).toBe(false)
})

test('does not match comment patterns', () => {
expect(matchGlob('#comment', '#comment')).toBe(false)
})
})
21 changes: 19 additions & 2 deletions packages/cli-kit/src/public/node/fs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {

import {sep, join} from 'pathe'
import {findUp as internalFindUp, findUpSync as internalFindUpSync} from 'find-up'
import {minimatch} from 'minimatch'
import {Minimatch} from 'minimatch'
import fastGlobLib from 'fast-glob'
import {
mkdirSync as fsMkdirSync,
Expand Down Expand Up @@ -706,6 +706,23 @@ export interface MatchGlobOptions {
noglobstar: boolean
}

// `minimatch()` parses the pattern into a new Minimatch instance on every call, which dominates the
// cost when the same handful of patterns is matched against thousands of paths (theme ignore filters
// run over every file of a theme, the app file watcher over every event). Compiled matchers are
// stateless, so caching them by pattern and options makes repeated matching ~24x faster.
const compiledGlobMatchers = new Map<string, Minimatch>()

function compiledGlobMatcher(pattern: string, options?: MatchGlobOptions): Minimatch {
const cacheKey = `${options?.matchBase ?? false}:${options?.noglobstar ?? false}:${pattern}`

let matcher = compiledGlobMatchers.get(cacheKey)
if (!matcher) {
matcher = new Minimatch(pattern, options)
compiledGlobMatchers.set(cacheKey, matcher)
}
return matcher
}

/**
* Matches a key against a glob pattern.
* @param key - The key to match.
Expand All @@ -714,7 +731,7 @@ export interface MatchGlobOptions {
* @returns true if the key matches the pattern, false otherwise.
*/
export function matchGlob(key: string, pattern: string, options?: MatchGlobOptions): boolean {
return minimatch(key, pattern, options)
return compiledGlobMatcher(pattern, options).match(key)
}

/**
Expand Down