From 4711b7a31b4aa89103d8c6ffab2e3b8e7b6381c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pavel=20Kut=C3=A1=C4=8D?= Date: Fri, 31 Jul 2026 23:44:02 +0200 Subject: [PATCH] feat: add 'some-with-excludes' predicate quantifier (#322) --- .../workflows/pull-request-verification.yml | 35 +++++ README.md | 43 ++++- __tests__/filter.test.ts | 148 ++++++++++++++++++ action.yml | 6 +- dist/index.js | 80 ++++++++-- src/filter.ts | 90 +++++++++-- 6 files changed, 363 insertions(+), 39 deletions(-) diff --git a/.github/workflows/pull-request-verification.yml b/.github/workflows/pull-request-verification.yml index 00899eb..13fe10d 100644 --- a/.github/workflows/pull-request-verification.yml +++ b/.github/workflows/pull-request-verification.yml @@ -167,6 +167,41 @@ jobs: if: steps.filter.outputs.local_count != 1 run: exit 1 + test-predicate-quantifier-some-with-excludes: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: modify working tree + run: | + mkdir -p mobile/.config + echo "TEST" > mobile/main.kt + echo "TEST" > mobile/README.md + echo "TEST" > mobile/.config/lint.json + echo "TEST" > backend.go + git add -A + - uses: ./ + id: filter + with: + base: HEAD + list-files: shell + predicate-quantifier: 'some-with-excludes' + filters: | + mobile: + - 'mobile/**' + - '!mobile/**/*.md' + - '!mobile/.config/**' + excludesOnly: + - '!**/*.md' + - name: Print 'mobile_files' + run: echo ${{steps.filter.outputs.mobile_files}} + - name: filter-test + if: | + steps.filter.outputs.mobile != 'true' + || steps.filter.outputs.mobile_count != 1 + || steps.filter.outputs.mobile_files != 'mobile/main.kt' + || steps.filter.outputs.excludesOnly != 'false' + run: exit 1 + test-change-type: runs-on: ubuntu-latest steps: diff --git a/README.md b/README.md index 2d4d320..333f848 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,7 @@ For more scenarios see [examples](#examples) section. ## What's New +- Add `some-with-excludes` value of the `predicate-quantifier` input parameter - Automatic workaround for git `dubious ownership` errors in container jobs - New major release `v4` after update to Node 24 [Breaking change] - Add `ref` input parameter @@ -172,14 +173,17 @@ For more information, see [CHANGELOG](https://github.com/dorny/paths-filter/blob token: '' # Optional parameter to override the default behavior of file matching algorithm. - # By default files that match at least one pattern defined by the filters will be included. - # This parameter allows to override the "at least one pattern" behavior to make it so that - # all of the patterns have to match or otherwise the file is excluded. + # Supported values: + # 'some' - File is included if it matches at least one pattern (default). + # 'every' - File is included only if it matches all of the patterns. + # 'some-with-excludes' - File is included if it matches at least one pattern + # and no negated pattern (the ones prefixed with '!'). + # # An example scenario where this is useful if you would like to match all # .ts files in a sub-directory but not .md files. # The filters below will match markdown files despite the exclusion syntax UNLESS - # you specify 'every' as the predicate-quantifier parameter. When you do that, - # it will only match the .ts files in the subdirectory as expected. + # you specify 'every' or 'some-with-excludes' as the predicate-quantifier parameter. + # When you do that, it will only match the .ts files in the subdirectory as expected. # # backend: # - 'pkg/a/b/c/**' @@ -197,6 +201,9 @@ For more information, see [CHANGELOG](https://github.com/dorny/paths-filter/blob - With `predicate-quantifier: 'every'`: - `'true'` - if **any** changed file matches **all** of the filter's rules - `'false'` - if **no** changed file matches **all** of the filter's rules + - With `predicate-quantifier: 'some-with-excludes'`: + - `'true'` - if **any** changed file matches **at least one** of the filter's rules and **none** of its negated rules + - `'false'` - if **no** changed file matches **at least one** of the filter's rules and **none** of its negated rules - Each filter sets an output variable with the name `${FILTER_NAME}_count` to the count of matching files. - If enabled, for each filter it sets an output variable with the name `${FILTER_NAME}_files`. It will contain a list of all files matching the filter. - `changes` - JSON array with names of all filters matching any of the changed files. @@ -533,6 +540,32 @@ jobs: +
+ Detect changes in multiple unrelated paths and exclude some file extensions + +```yaml +- uses: dorny/paths-filter@v4 + id: filter + with: + # With 'some-with-excludes' a file is matched when it matches at least one pattern + # and none of the negated ones. The filter below therefore matches all the files + # in the 'mobile' folder and the workflow file, but never a markdown file or + # anything in 'mobile/.config'. + # + # An exclusion is final - a file excluded by one pattern can't be included back + # by another one. Consequently, a filter consisting of negated patterns only + # never matches anything. + predicate-quantifier: 'some-with-excludes' + filters: | + mobile: + - 'mobile/**' + - '!mobile/**/*.md' + - '!mobile/.config/**' + - '.github/workflows/test_mobile.yml' +``` + +
+ ### Custom processing of changed files
diff --git a/__tests__/filter.test.ts b/__tests__/filter.test.ts index 7d7da94..832824e 100644 --- a/__tests__/filter.test.ts +++ b/__tests__/filter.test.ts @@ -148,6 +148,99 @@ describe('matching tests', () => { expect(otherPkgJpegMatch.backend).toEqual([]) }) + test('ignores exclusions when using the default predicate quantifier', () => { + const yaml = ` + src: + - 'src/**' + - '!**/*.md' + ` + const filter = new Filter(yaml) + + // A negated pattern is just another pattern for the 'some' quantifier - a markdown file + // inside 'src' still matches 'src/**' and any other file matches the negated pattern. + const files = modified(['src/README.md', 'other/file.txt']) + expect(filter.match(files).src).toEqual(files) + }) + + test('matches files of every pattern when set to PredicateQuantifier.SOME_WITH_EXCLUDES', () => { + const yaml = ` + mobile: + - 'mobile/**' + - '!mobile/**/*.md' + - '!mobile/.config/**' + - '.github/workflows/test_mobile.yml' + ` + const filterConfig: FilterConfig = {predicateQuantifier: PredicateQuantifier.SOME_WITH_EXCLUDES} + const filter = new Filter(yaml, filterConfig) + + const sourceFiles = modified(['mobile/main.kt', 'mobile/src/some/Activity.kt']) + const workflowFiles = modified(['.github/workflows/test_mobile.yml']) + const docsFiles = modified(['mobile/README.md', 'mobile/docs/some/page.md']) + const configFiles = modified(['mobile/.config/lint.json', 'mobile/.config/nested/lint.json']) + const otherFiles = modified(['backend/main.go', '.github/workflows/test_backend.yml']) + + expect(filter.match(sourceFiles).mobile).toEqual(sourceFiles) + expect(filter.match(workflowFiles).mobile).toEqual(workflowFiles) + expect(filter.match(docsFiles).mobile).toEqual([]) + expect(filter.match(configFiles).mobile).toEqual([]) + expect(filter.match(otherFiles).mobile).toEqual([]) + }) + + test('excludes file with PredicateQuantifier.SOME_WITH_EXCLUDES regardless of the pattern order', () => { + const yaml = ` + excludeFirst: + - '!**/*.md' + - 'src/**' + excludeLast: + - 'src/**' + - '!**/*.md' + ` + const filterConfig: FilterConfig = {predicateQuantifier: PredicateQuantifier.SOME_WITH_EXCLUDES} + const filter = new Filter(yaml, filterConfig) + + const match = filter.match(modified(['src/index.ts', 'src/README.md'])) + expect(match.excludeFirst).toEqual(modified(['src/index.ts'])) + expect(match.excludeLast).toEqual(modified(['src/index.ts'])) + }) + + test('keeps file excluded with PredicateQuantifier.SOME_WITH_EXCLUDES even if a later pattern includes it', () => { + const yaml = ` + src: + - 'src/**' + - '!**/*.md' + - 'src/docs/**' + ` + const filterConfig: FilterConfig = {predicateQuantifier: PredicateQuantifier.SOME_WITH_EXCLUDES} + const filter = new Filter(yaml, filterConfig) + + const match = filter.match(modified(['src/docs/guide.md', 'src/docs/logo.png'])) + expect(match.src).toEqual(modified(['src/docs/logo.png'])) + }) + + test('matches nothing with PredicateQuantifier.SOME_WITH_EXCLUDES when there is no include pattern', () => { + const yaml = ` + src: + - '!**/*.md' + ` + const filterConfig: FilterConfig = {predicateQuantifier: PredicateQuantifier.SOME_WITH_EXCLUDES} + const filter = new Filter(yaml, filterConfig) + + const match = filter.match(modified(['src/index.ts', 'src/README.md'])) + expect(match.src).toEqual([]) + }) + + test('treats negated extglob as an include pattern with PredicateQuantifier.SOME_WITH_EXCLUDES', () => { + const yaml = ` + backend: + - '!(**/*.tsx|**/*.less)' + ` + const filterConfig: FilterConfig = {predicateQuantifier: PredicateQuantifier.SOME_WITH_EXCLUDES} + const filter = new Filter(yaml, filterConfig) + + expect(filter.match(modified(['src/server.py'])).backend).toEqual(modified(['src/server.py'])) + expect(filter.match(modified(['src/ui.tsx'])).backend).toEqual([]) + }) + test('matches path based on rules included using YAML anchor', () => { const yaml = ` shared: &shared @@ -197,6 +290,61 @@ describe('matching specific change status', () => { expect(match.addOrModify).toEqual(files) }) + test('respects change status of exclude patterns when set to PredicateQuantifier.SOME_WITH_EXCLUDES', () => { + const yaml = ` + src: + - 'src/**' + - deleted: '!src/generated/**' + ` + const filterConfig: FilterConfig = {predicateQuantifier: PredicateQuantifier.SOME_WITH_EXCLUDES} + const filter = new Filter(yaml, filterConfig) + + const files = [ + {status: ChangeStatus.Deleted, filename: 'src/generated/api.ts'}, + {status: ChangeStatus.Modified, filename: 'src/generated/api.ts'} + ] + const match = filter.match(files) + expect(match.src).toEqual([files[1]]) + }) + + test('matches multiple patterns of single change status when set to PredicateQuantifier.SOME_WITH_EXCLUDES', () => { + const yaml = ` + docs: &docs + - '!**/*.md' + src: + - added|modified: 'src/**' + - added|modified: *docs + ` + const filterConfig: FilterConfig = {predicateQuantifier: PredicateQuantifier.SOME_WITH_EXCLUDES} + const filter = new Filter(yaml, filterConfig) + + const files = [ + {status: ChangeStatus.Added, filename: 'src/index.ts'}, + {status: ChangeStatus.Added, filename: 'src/README.md'}, + {status: ChangeStatus.Deleted, filename: 'src/legacy.ts'} + ] + const match = filter.match(files) + expect(match.src).toEqual([files[0]]) + }) + + test('or-es patterns of single change status when using the default predicate quantifier', () => { + const yaml = ` + src: + - added|modified: ['src/**', '!**/*.md'] + ` + const filter = new Filter(yaml) + + // Both patterns are OR-ed into a single rule, therefore a markdown file inside 'src' + // matches through 'src/**' and any other file matches through the negated pattern. + const files = [ + {status: ChangeStatus.Added, filename: 'src/README.md'}, + {status: ChangeStatus.Added, filename: 'other/file.txt'}, + {status: ChangeStatus.Deleted, filename: 'src/index.ts'} + ] + const match = filter.match(files) + expect(match.src).toEqual([files[0], files[1]]) + }) + test('matches when using an anchor', () => { const yaml = ` shared: &shared diff --git a/action.yml b/action.yml index 914524f..a55d631 100644 --- a/action.yml +++ b/action.yml @@ -46,7 +46,11 @@ inputs: default: '100' predicate-quantifier: description: | - allows to override the "at least one pattern" behavior to make it so that all of the patterns have to match or otherwise the file is excluded. + allows to override the "at least one pattern" behavior: + 'some' - file is included if it matches at least one pattern (default). + 'every' - file is included only if it matches all of the patterns. + 'some-with-excludes' - file is included if it matches at least one pattern + and no negated pattern (the ones prefixed with '!'). required: false default: 'some' outputs: diff --git a/dist/index.js b/dist/index.js index 10c9fda..54ba724 100644 --- a/dist/index.js +++ b/dist/index.js @@ -86,6 +86,16 @@ var PredicateQuantifier; * specify anything as a predicate quantifier. */ PredicateQuantifier["SOME"] = "some"; + /** + * When choosing 'some-with-excludes' in the config it means that files will get matched if + * at least one of the patterns matches them and none of the negated patterns (the ones + * prefixed with '!') matches them. An exclusion is final - a file excluded by one pattern + * can't be included back by another one. + * + * A filter which consists of negated patterns only never matches anything, + * because there is no pattern which could include a file in the first place. + */ + PredicateQuantifier["SOME_WITH_EXCLUDES"] = "some-with-excludes"; })(PredicateQuantifier || (exports.PredicateQuantifier = PredicateQuantifier = {})); /** * An array of strings (at runtime) that contains the valid/accepted values for @@ -126,15 +136,35 @@ class Filter { return result; } isMatch(file, patterns) { - var _a; - const aPredicate = (rule) => { - return (rule.status === undefined || rule.status.includes(file.status)) && rule.isMatch(file.filename); + var _a, _b, _c; + const isStatusMatch = (rule) => { + return rule.status === undefined || rule.status.includes(file.status); }; - if (((_a = this.filterConfig) === null || _a === void 0 ? void 0 : _a.predicateQuantifier) === 'every') { - return patterns.every(aPredicate); - } - else { - return patterns.some(aPredicate); + const aPredicate = (rule) => { + return isStatusMatch(rule) && rule.isMatch(file.filename); + }; + switch ((_a = this.filterConfig) === null || _a === void 0 ? void 0 : _a.predicateQuantifier) { + case PredicateQuantifier.EVERY: + return patterns.every(aPredicate); + case PredicateQuantifier.SOME_WITH_EXCLUDES: { + let isIncluded = false; + for (const rule of patterns) { + if (!isStatusMatch(rule)) { + continue; + } + // Once a file is excluded it stays excluded - no other pattern can include it back. + // Therefore all the patterns have to be evaluated even if the file is already included. + if ((_b = rule.isExclude) === null || _b === void 0 ? void 0 : _b.call(rule, file.filename)) { + return false; + } + if (!isIncluded && ((_c = rule.isInclude) === null || _c === void 0 ? void 0 : _c.call(rule, file.filename))) { + isIncluded = true; + } + } + return isIncluded; + } + default: + return patterns.some(aPredicate); } } parseFilterItemYaml(item) { @@ -142,21 +172,19 @@ class Filter { return flat(item.map(i => this.parseFilterItemYaml(i))); } if (typeof item === 'string') { - return [{ status: undefined, isMatch: (0, picomatch_1.default)(item, MatchOptions) }]; + return [createRuleItem(item)]; } if (typeof item === 'object') { return Object.entries(item).map(([key, pattern]) => { if (typeof key !== 'string' || (typeof pattern !== 'string' && !Array.isArray(pattern))) { this.throwInvalidFormatError(`Expected [key:string]= pattern:string | string[], but [${key}:${typeof key}]= ${pattern}:${typeof pattern} found`); } - return { - status: key - .split('|') - .map(x => x.trim()) - .filter(x => x.length > 0) - .map(x => x.toLowerCase()), - isMatch: (0, picomatch_1.default)(pattern, MatchOptions) - }; + const status = key + .split('|') + .map(x => x.trim()) + .filter(x => x.length > 0) + .map(x => x.toLowerCase()); + return createRuleItem(pattern, status); }); } this.throwInvalidFormatError(`Unexpected element type '${typeof item}'`); @@ -171,6 +199,24 @@ exports.Filter = Filter; function flat(arr) { return arr.reduce((acc, val) => acc.concat(val), []); } +// Compiles filename pattern(s) of a single filter rule item into matchers. +// Multiple patterns are OR-ed together, which is how picomatch treats an array of globs. +// Patterns are also split by their polarity, so PredicateQuantifier.SOME_WITH_EXCLUDES +// can tell inclusions from exclusions. Note that only a leading '!' negates the whole +// pattern - the '!(...)' extglob is a regular pattern matching everything it doesn't enumerate. +function createRuleItem(patterns, status) { + const matchers = (Array.isArray(patterns) ? patterns : [patterns]).map(pattern => (0, picomatch_1.default)(pattern, MatchOptions, true)); + // picomatch inverts the result of a matcher created from a negated pattern. + // Inverting it back gives a matcher of the filenames such pattern excludes. + const includes = matchers.filter(matcher => !matcher.state.negated); + const excludes = matchers.filter(matcher => matcher.state.negated); + return { + status, + isMatch: str => matchers.some(matcher => matcher(str)), + isInclude: includes.length > 0 ? str => includes.some(matcher => matcher(str)) : undefined, + isExclude: excludes.length > 0 ? str => excludes.some(matcher => !matcher(str)) : undefined + }; +} /***/ }), diff --git a/src/filter.ts b/src/filter.ts index 1947ef8..849495d 100644 --- a/src/filter.ts +++ b/src/filter.ts @@ -21,6 +21,12 @@ const MatchOptions = { interface FilterRuleItem { status?: ChangeStatus[] // Required change status of the matched files isMatch: (str: string) => boolean // Matches the filename + // Matchers for the individual polarities of the patterns this item was created from. + // They are used only by the PredicateQuantifier.SOME_WITH_EXCLUDES quantifier, + // which has to tell inclusions and exclusions apart. The other quantifiers keep + // evaluating the item as a whole via 'isMatch'. + isInclude?: (str: string) => boolean // Matches any of the patterns which are not negated + isExclude?: (str: string) => boolean // Matches any of the patterns which are negated (e.g. '!**/*.md') } /** @@ -47,7 +53,17 @@ export enum PredicateQuantifier { * at least one pattern that matches them. This is the default behavior if you don't * specify anything as a predicate quantifier. */ - SOME = 'some' + SOME = 'some', + /** + * When choosing 'some-with-excludes' in the config it means that files will get matched if + * at least one of the patterns matches them and none of the negated patterns (the ones + * prefixed with '!') matches them. An exclusion is final - a file excluded by one pattern + * can't be included back by another one. + * + * A filter which consists of negated patterns only never matches anything, + * because there is no pattern which could include a file in the first place. + */ + SOME_WITH_EXCLUDES = 'some-with-excludes' } /** @@ -104,13 +120,35 @@ export class Filter { } private isMatch(file: File, patterns: FilterRuleItem[]): boolean { - const aPredicate = (rule: Readonly): boolean => { - return (rule.status === undefined || rule.status.includes(file.status)) && rule.isMatch(file.filename) + const isStatusMatch = (rule: Readonly): boolean => { + return rule.status === undefined || rule.status.includes(file.status) } - if (this.filterConfig?.predicateQuantifier === 'every') { - return patterns.every(aPredicate) - } else { - return patterns.some(aPredicate) + const aPredicate = (rule: Readonly): boolean => { + return isStatusMatch(rule) && rule.isMatch(file.filename) + } + + switch (this.filterConfig?.predicateQuantifier) { + case PredicateQuantifier.EVERY: + return patterns.every(aPredicate) + case PredicateQuantifier.SOME_WITH_EXCLUDES: { + let isIncluded = false + for (const rule of patterns) { + if (!isStatusMatch(rule)) { + continue + } + // Once a file is excluded it stays excluded - no other pattern can include it back. + // Therefore all the patterns have to be evaluated even if the file is already included. + if (rule.isExclude?.(file.filename)) { + return false + } + if (!isIncluded && rule.isInclude?.(file.filename)) { + isIncluded = true + } + } + return isIncluded + } + default: + return patterns.some(aPredicate) } } @@ -120,7 +158,7 @@ export class Filter { } if (typeof item === 'string') { - return [{status: undefined, isMatch: picomatch(item, MatchOptions)}] + return [createRuleItem(item)] } if (typeof item === 'object') { @@ -130,14 +168,12 @@ export class Filter { `Expected [key:string]= pattern:string | string[], but [${key}:${typeof key}]= ${pattern}:${typeof pattern} found` ) } - return { - status: key - .split('|') - .map(x => x.trim()) - .filter(x => x.length > 0) - .map(x => x.toLowerCase()) as ChangeStatus[], - isMatch: picomatch(pattern, MatchOptions) - } + const status = key + .split('|') + .map(x => x.trim()) + .filter(x => x.length > 0) + .map(x => x.toLowerCase()) as ChangeStatus[] + return createRuleItem(pattern, status) }) } @@ -154,3 +190,25 @@ export class Filter { function flat(arr: T[][]): T[] { return arr.reduce((acc, val) => acc.concat(val), []) } + +// Compiles filename pattern(s) of a single filter rule item into matchers. +// Multiple patterns are OR-ed together, which is how picomatch treats an array of globs. +// Patterns are also split by their polarity, so PredicateQuantifier.SOME_WITH_EXCLUDES +// can tell inclusions from exclusions. Note that only a leading '!' negates the whole +// pattern - the '!(...)' extglob is a regular pattern matching everything it doesn't enumerate. +function createRuleItem(patterns: string | string[], status?: ChangeStatus[]): FilterRuleItem { + const matchers = (Array.isArray(patterns) ? patterns : [patterns]).map(pattern => + picomatch(pattern, MatchOptions, true) + ) + // picomatch inverts the result of a matcher created from a negated pattern. + // Inverting it back gives a matcher of the filenames such pattern excludes. + const includes = matchers.filter(matcher => !matcher.state.negated) + const excludes = matchers.filter(matcher => matcher.state.negated) + + return { + status, + isMatch: str => matchers.some(matcher => matcher(str)), + isInclude: includes.length > 0 ? str => includes.some(matcher => matcher(str)) : undefined, + isExclude: excludes.length > 0 ? str => excludes.some(matcher => !matcher(str)) : undefined + } +}