diff --git a/README.md b/README.md index e52115ea..b5e0f4cf 100644 --- a/README.md +++ b/README.md @@ -153,6 +153,22 @@ For more information, see [CHANGELOG](https://github.com/dorny/paths-filter/blob # changes using git commands. # Default: ${{ github.token }} 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. + # 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. + # + # backend: + # - 'pkg/a/b/c/**' + # - '!**/*.jpeg' + # - '!**/*.md' + predicate-quantifier: 'some' ``` ## Outputs @@ -463,6 +479,32 @@ jobs: +
+ Detect changes in folder only for some file extensions + +```yaml +- uses: dorny/paths-filter@v3 + id: filter + with: + # This makes it so that all the patterns have to match a file for it to be + # considered changed. Because we have the exclusions for .jpeg and .md files + # the end result is that if those files are changed they will be ignored + # because they don't match the respective rules excluding them. + # + # 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. + predicate-quantifier: 'every' + filters: | + backend: + - 'pkg/a/b/c/**' + - '!**/*.jpeg' + - '!**/*.md' +``` + +
+ ### Custom processing of changed files
diff --git a/__tests__/filter.test.ts b/__tests__/filter.test.ts index be2a1487..7d7da947 100644 --- a/__tests__/filter.test.ts +++ b/__tests__/filter.test.ts @@ -1,4 +1,4 @@ -import {Filter} from '../src/filter' +import {Filter, FilterConfig, PredicateQuantifier} from '../src/filter' import {File, ChangeStatus} from '../src/file' describe('yaml filter parsing tests', () => { @@ -117,6 +117,37 @@ describe('matching tests', () => { expect(pyMatch.backend).toEqual(pyFiles) }) + test('matches only files that are matching EVERY pattern when set to PredicateQuantifier.EVERY', () => { + const yaml = ` + backend: + - 'pkg/a/b/c/**' + - '!**/*.jpeg' + - '!**/*.md' + ` + const filterConfig: FilterConfig = {predicateQuantifier: PredicateQuantifier.EVERY} + const filter = new Filter(yaml, filterConfig) + + const typescriptFiles = modified(['pkg/a/b/c/some-class.ts', 'pkg/a/b/c/src/main/some-class.ts']) + const otherPkgTypescriptFiles = modified(['pkg/x/y/z/some-class.ts', 'pkg/x/y/z/src/main/some-class.ts']) + const otherPkgJpegFiles = modified(['pkg/x/y/z/some-pic.jpeg', 'pkg/x/y/z/src/main/jpeg/some-pic.jpeg']) + const docsFiles = modified([ + 'pkg/a/b/c/some-pics.jpeg', + 'pkg/a/b/c/src/main/jpeg/some-pic.jpeg', + 'pkg/a/b/c/src/main/some-docs.md', + 'pkg/a/b/c/some-docs.md' + ]) + + const typescriptMatch = filter.match(typescriptFiles) + const otherPkgTypescriptMatch = filter.match(otherPkgTypescriptFiles) + const docsMatch = filter.match(docsFiles) + const otherPkgJpegMatch = filter.match(otherPkgJpegFiles) + + expect(typescriptMatch.backend).toEqual(typescriptFiles) + expect(otherPkgTypescriptMatch.backend).toEqual([]) + expect(docsMatch.backend).toEqual([]) + expect(otherPkgJpegMatch.backend).toEqual([]) + }) + test('matches path based on rules included using YAML anchor', () => { const yaml = ` shared: &shared @@ -186,3 +217,9 @@ function modified(paths: string[]): File[] { return {filename, status: ChangeStatus.Modified} }) } + +function renamed(paths: string[]): File[] { + return paths.map(filename => { + return {filename, status: ChangeStatus.Renamed} + }) +} diff --git a/src/filter.ts b/src/filter.ts index d0428e4b..2b201fb2 100644 --- a/src/filter.ts +++ b/src/filter.ts @@ -23,6 +23,48 @@ interface FilterRuleItem { isMatch: (str: string) => boolean // Matches the filename } +/** + * Enumerates the possible logic quantifiers that can be used when determining + * if a file is a match or not with multiple patterns. + * + * The YAML configuration property that is parsed into one of these values is + * 'predicate-quantifier' on the top level of the configuration object of the + * action. + * + * The default is to use 'some' which used to be the hardcoded behavior prior to + * the introduction of the new mechanism. + * + * @see https://en.wikipedia.org/wiki/Quantifier_(logic) + */ +export enum PredicateQuantifier { + /** + * When choosing 'every' in the config it means that files will only get matched + * if all the patterns are satisfied by the path of the file, not just at least one of them. + */ + EVERY = 'every', + /** + * When choosing 'some' in the config it means that files will get matched as long as there is + * at least one pattern that matches them. This is the default behavior if you don't + * specify anything as a predicate quantifier. + */ + SOME = 'some' +} + +/** + * Used to define customizations for how the file filtering should work at runtime. + */ +export type FilterConfig = {readonly predicateQuantifier: PredicateQuantifier} + +/** + * An array of strings (at runtime) that contains the valid/accepted values for + * the configuration parameter 'predicate-quantifier'. + */ +export const SUPPORTED_PREDICATE_QUANTIFIERS = Object.values(PredicateQuantifier) + +export function isPredicateQuantifier(x: unknown): x is PredicateQuantifier { + return SUPPORTED_PREDICATE_QUANTIFIERS.includes(x as PredicateQuantifier) +} + export interface FilterResults { [key: string]: File[] } @@ -31,7 +73,7 @@ export class Filter { rules: {[key: string]: FilterRuleItem[]} = {} // Creates instance of Filter and load rules from YAML if it's provided - constructor(yaml?: string) { + constructor(yaml?: string, public readonly filterConfig?: FilterConfig) { if (yaml) { this.load(yaml) } @@ -62,9 +104,14 @@ export class Filter { } private isMatch(file: File, patterns: FilterRuleItem[]): boolean { - return patterns.some( - rule => (rule.status === undefined || rule.status.includes(file.status)) && rule.isMatch(file.filename) - ) + const aPredicate = (rule: Readonly) => { + return (rule.status === undefined || rule.status.includes(file.status)) && rule.isMatch(file.filename) + } + if (this.filterConfig?.predicateQuantifier === 'every') { + return patterns.every(aPredicate) + } else { + return patterns.some(aPredicate) + } } private parseFilterItemYaml(item: FilterItemYaml): FilterRuleItem[] { diff --git a/src/main.ts b/src/main.ts index 18daefbb..8320287c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -4,7 +4,14 @@ import * as github from '@actions/github' import {GetResponseDataTypeFromEndpointMethod} from '@octokit/types' import {PushEvent, PullRequestEvent} from '@octokit/webhooks-types' -import {Filter, FilterResults} from './filter' +import { + isPredicateQuantifier, + Filter, + FilterConfig, + FilterResults, + PredicateQuantifier, + SUPPORTED_PREDICATE_QUANTIFIERS +} from './filter' import {File, ChangeStatus} from './file' import * as git from './git' import {backslashEscape, shellEscape} from './list-format/shell-escape' @@ -26,13 +33,22 @@ async function run(): Promise { const filtersYaml = isPathInput(filtersInput) ? getConfigFileContent(filtersInput) : filtersInput const listFiles = core.getInput('list-files', {required: false}).toLowerCase() || 'none' const initialFetchDepth = parseInt(core.getInput('initial-fetch-depth', {required: false})) || 10 + const predicateQuantifier = core.getInput('predicate-quantifier', {required: false}) || PredicateQuantifier.SOME if (!isExportFormat(listFiles)) { core.setFailed(`Input parameter 'list-files' is set to invalid value '${listFiles}'`) return } - const filter = new Filter(filtersYaml) + if (!isPredicateQuantifier(predicateQuantifier)) { + const predicateQuantifierInvalidErrorMsg = + `Input parameter 'predicate-quantifier' is set to invalid value ` + + `'${predicateQuantifier}'. Valid values: ${SUPPORTED_PREDICATE_QUANTIFIERS.join(', ')}` + throw new Error(predicateQuantifierInvalidErrorMsg) + } + const filterConfig: FilterConfig = {predicateQuantifier} + + const filter = new Filter(filtersYaml, filterConfig) const files = await getChangedFiles(token, base, ref, initialFetchDepth) core.info(`Detected ${files.length} changed files`) const results = filter.match(files)