Skip to content

Commit f90d526

Browse files
committed
feat: add config parameter for predicate quantifier
Setting the new 'predicate-quantifier' configuration parameter to 'every' makes it so that all the patterns have to match a file for it to be considered changed. This can be leveraged to ensure that you only build & test software changes that have real impact on the behavior of the code, e.g. you can set up your build to run when Typescript/Rust/etc. files are changed but markdown changes in the diff will be ignored and you consume less resources to build. The default behavior does not change by the introduction of this feature so upgrading can be done safely knowing that existing workflows will not break. Signed-off-by: Peter Somogyvari <peter.somogyvari@accenture.com>
1 parent ebc4d7e commit f90d526

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)