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
+ }
+}