Skip to content

Commit cf89abd

Browse files
authored
Merge pull request #224 from petermetz/feat-filter-predicate-quantifier
feat: add config parameter for predicate quantifier
2 parents ebc4d7e + f90d526 commit cf89abd

File tree

4 files changed

+149
-7
lines changed

4 files changed

+149
-7
lines changed

README.md

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,22 @@ For more information, see [CHANGELOG](https://github.com/dorny/paths-filter/blob
153153
# changes using git commands.
154154
# Default: ${{ github.token }}
155155
token: ''
156+
157+
# Optional parameter to override the default behavior of file matching algorithm.
158+
# By default files that match at least one pattern defined by the filters will be included.
159+
# This parameter allows to override the "at least one pattern" behavior to make it so that
160+
# all of the patterns have to match or otherwise the file is excluded.
161+
# An example scenario where this is useful if you would like to match all
162+
# .ts files in a sub-directory but not .md files.
163+
# The filters below will match markdown files despite the exclusion syntax UNLESS
164+
# you specify 'every' as the predicate-quantifier parameter. When you do that,
165+
# it will only match the .ts files in the subdirectory as expected.
166+
#
167+
# backend:
168+
# - 'pkg/a/b/c/**'
169+
# - '!**/*.jpeg'
170+
# - '!**/*.md'
171+
predicate-quantifier: 'some'
156172
```
157173

158174
## Outputs
@@ -463,6 +479,32 @@ jobs:
463479
464480
</details>
465481
482+
<details>
483+
<summary>Detect changes in folder only for some file extensions</summary>
484+
485+
```yaml
486+
- uses: dorny/paths-filter@v3
487+
id: filter
488+
with:
489+
# This makes it so that all the patterns have to match a file for it to be
490+
# considered changed. Because we have the exclusions for .jpeg and .md files
491+
# the end result is that if those files are changed they will be ignored
492+
# because they don't match the respective rules excluding them.
493+
#
494+
# This can be leveraged to ensure that you only build & test software changes
495+
# that have real impact on the behavior of the code, e.g. you can set up your
496+
# build to run when Typescript/Rust/etc. files are changed but markdown
497+
# changes in the diff will be ignored and you consume less resources to build.
498+
predicate-quantifier: 'every'
499+
filters: |
500+
backend:
501+
- 'pkg/a/b/c/**'
502+
- '!**/*.jpeg'
503+
- '!**/*.md'
504+
```
505+
506+
</details>
507+
466508
### Custom processing of changed files
467509
468510
<details>

__tests__/filter.test.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {Filter} from '../src/filter'
1+
import {Filter, FilterConfig, PredicateQuantifier} from '../src/filter'
22
import {File, ChangeStatus} from '../src/file'
33

44
describe('yaml filter parsing tests', () => {
@@ -117,6 +117,37 @@ describe('matching tests', () => {
117117
expect(pyMatch.backend).toEqual(pyFiles)
118118
})
119119

120+
test('matches only files that are matching EVERY pattern when set to PredicateQuantifier.EVERY', () => {
121+
const yaml = `
122+
backend:
123+
- 'pkg/a/b/c/**'
124+
- '!**/*.jpeg'
125+
- '!**/*.md'
126+
`
127+
const filterConfig: FilterConfig = {predicateQuantifier: PredicateQuantifier.EVERY}
128+
const filter = new Filter(yaml, filterConfig)
129+
130+
const typescriptFiles = modified(['pkg/a/b/c/some-class.ts', 'pkg/a/b/c/src/main/some-class.ts'])
131+
const otherPkgTypescriptFiles = modified(['pkg/x/y/z/some-class.ts', 'pkg/x/y/z/src/main/some-class.ts'])
132+
const otherPkgJpegFiles = modified(['pkg/x/y/z/some-pic.jpeg', 'pkg/x/y/z/src/main/jpeg/some-pic.jpeg'])
133+
const docsFiles = modified([
134+
'pkg/a/b/c/some-pics.jpeg',
135+
'pkg/a/b/c/src/main/jpeg/some-pic.jpeg',
136+
'pkg/a/b/c/src/main/some-docs.md',
137+
'pkg/a/b/c/some-docs.md'
138+
])
139+
140+
const typescriptMatch = filter.match(typescriptFiles)
141+
const otherPkgTypescriptMatch = filter.match(otherPkgTypescriptFiles)
142+
const docsMatch = filter.match(docsFiles)
143+
const otherPkgJpegMatch = filter.match(otherPkgJpegFiles)
144+
145+
expect(typescriptMatch.backend).toEqual(typescriptFiles)
146+
expect(otherPkgTypescriptMatch.backend).toEqual([])
147+
expect(docsMatch.backend).toEqual([])
148+
expect(otherPkgJpegMatch.backend).toEqual([])
149+
})
150+
120151
test('matches path based on rules included using YAML anchor', () => {
121152
const yaml = `
122153
shared: &shared
@@ -186,3 +217,9 @@ function modified(paths: string[]): File[] {
186217
return {filename, status: ChangeStatus.Modified}
187218
})
188219
}
220+
221+
function renamed(paths: string[]): File[] {
222+
return paths.map(filename => {
223+
return {filename, status: ChangeStatus.Renamed}
224+
})
225+
}

src/filter.ts

Lines changed: 51 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,48 @@ interface FilterRuleItem {
2323
isMatch: (str: string) => boolean // Matches the filename
2424
}
2525

26+
/**
27+
* Enumerates the possible logic quantifiers that can be used when determining
28+
* if a file is a match or not with multiple patterns.
29+
*
30+
* The YAML configuration property that is parsed into one of these values is
31+
* 'predicate-quantifier' on the top level of the configuration object of the
32+
* action.
33+
*
34+
* The default is to use 'some' which used to be the hardcoded behavior prior to
35+
* the introduction of the new mechanism.
36+
*
37+
* @see https://en.wikipedia.org/wiki/Quantifier_(logic)
38+
*/
39+
export enum PredicateQuantifier {
40+
/**
41+
* When choosing 'every' in the config it means that files will only get matched
42+
* if all the patterns are satisfied by the path of the file, not just at least one of them.
43+
*/
44+
EVERY = 'every',
45+
/**
46+
* When choosing 'some' in the config it means that files will get matched as long as there is
47+
* at least one pattern that matches them. This is the default behavior if you don't
48+
* specify anything as a predicate quantifier.
49+
*/
50+
SOME = 'some'
51+
}
52+
53+
/**
54+
* Used to define customizations for how the file filtering should work at runtime.
55+
*/
56+
export type FilterConfig = {readonly predicateQuantifier: PredicateQuantifier}
57+
58+
/**
59+
* An array of strings (at runtime) that contains the valid/accepted values for
60+
* the configuration parameter 'predicate-quantifier'.
61+
*/
62+
export const SUPPORTED_PREDICATE_QUANTIFIERS = Object.values(PredicateQuantifier)
63+
64+
export function isPredicateQuantifier(x: unknown): x is PredicateQuantifier {
65+
return SUPPORTED_PREDICATE_QUANTIFIERS.includes(x as PredicateQuantifier)
66+
}
67+
2668
export interface FilterResults {
2769
[key: string]: File[]
2870
}
@@ -31,7 +73,7 @@ export class Filter {
3173
rules: {[key: string]: FilterRuleItem[]} = {}
3274

3375
// Creates instance of Filter and load rules from YAML if it's provided
34-
constructor(yaml?: string) {
76+
constructor(yaml?: string, public readonly filterConfig?: FilterConfig) {
3577
if (yaml) {
3678
this.load(yaml)
3779
}
@@ -62,9 +104,14 @@ export class Filter {
62104
}
63105

64106
private isMatch(file: File, patterns: FilterRuleItem[]): boolean {
65-
return patterns.some(
66-
rule => (rule.status === undefined || rule.status.includes(file.status)) && rule.isMatch(file.filename)
67-
)
107+
const aPredicate = (rule: Readonly<FilterRuleItem>) => {
108+
return (rule.status === undefined || rule.status.includes(file.status)) && rule.isMatch(file.filename)
109+
}
110+
if (this.filterConfig?.predicateQuantifier === 'every') {
111+
return patterns.every(aPredicate)
112+
} else {
113+
return patterns.some(aPredicate)
114+
}
68115
}
69116

70117
private parseFilterItemYaml(item: FilterItemYaml): FilterRuleItem[] {

src/main.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,14 @@ import * as github from '@actions/github'
44
import {GetResponseDataTypeFromEndpointMethod} from '@octokit/types'
55
import {PushEvent, PullRequestEvent} from '@octokit/webhooks-types'
66

7-
import {Filter, FilterResults} from './filter'
7+
import {
8+
isPredicateQuantifier,
9+
Filter,
10+
FilterConfig,
11+
FilterResults,
12+
PredicateQuantifier,
13+
SUPPORTED_PREDICATE_QUANTIFIERS
14+
} from './filter'
815
import {File, ChangeStatus} from './file'
916
import * as git from './git'
1017
import {backslashEscape, shellEscape} from './list-format/shell-escape'
@@ -26,13 +33,22 @@ async function run(): Promise<void> {
2633
const filtersYaml = isPathInput(filtersInput) ? getConfigFileContent(filtersInput) : filtersInput
2734
const listFiles = core.getInput('list-files', {required: false}).toLowerCase() || 'none'
2835
const initialFetchDepth = parseInt(core.getInput('initial-fetch-depth', {required: false})) || 10
36+
const predicateQuantifier = core.getInput('predicate-quantifier', {required: false}) || PredicateQuantifier.SOME
2937

3038
if (!isExportFormat(listFiles)) {
3139
core.setFailed(`Input parameter 'list-files' is set to invalid value '${listFiles}'`)
3240
return
3341
}
3442

35-
const filter = new Filter(filtersYaml)
43+
if (!isPredicateQuantifier(predicateQuantifier)) {
44+
const predicateQuantifierInvalidErrorMsg =
45+
`Input parameter 'predicate-quantifier' is set to invalid value ` +
46+
`'${predicateQuantifier}'. Valid values: ${SUPPORTED_PREDICATE_QUANTIFIERS.join(', ')}`
47+
throw new Error(predicateQuantifierInvalidErrorMsg)
48+
}
49+
const filterConfig: FilterConfig = {predicateQuantifier}
50+
51+
const filter = new Filter(filtersYaml, filterConfig)
3652
const files = await getChangedFiles(token, base, ref, initialFetchDepth)
3753
core.info(`Detected ${files.length} changed files`)
3854
const results = filter.match(files)

0 commit comments

Comments
 (0)