26 Commits

Author SHA1 Message Date
Michal Dorner
ca8fa4002c Merge pull request #75 from dorny/fix-searching-for-merge-base
Fetch base and search merge-base without creating local branch
2021-03-09 22:05:03 +01:00
Michal Dorner
c64be944bf Update CHANGELOG 2021-03-09 22:01:10 +01:00
Michal Dorner
138368ff4f Use ref instead of HEAD 2021-03-09 21:56:18 +01:00
Michal Dorner
a301a0ad83 Refactor getChangesSinceMergeBase() code 2021-03-09 21:44:15 +01:00
Michal Dorner
0c0d1a854a Fetch base and search merge-base without creating local branch 2021-03-09 21:13:57 +01:00
Michal Dorner
0aa1597c2b Merge pull request #74 from dorny/fix-searching-for-merge-base
Fix fetching git history + fallback to unshallow repo
2021-03-08 17:11:16 +01:00
Michal Dorner
46d2898cef Update CHANGELOG 2021-03-08 17:10:14 +01:00
Michal Dorner
c90ecaa5a1 Increase default value of initial-fetch-depth to 100
For most situation it should be enough to find merge base. Previous value was too slow and overhead of doing fetch was significantly higher than saving of transfer.
2021-03-08 17:06:12 +01:00
Michal Dorner
49abb091ed Use combination of --depth and --deepen 2021-03-08 17:00:52 +01:00
Michal Dorner
8801c887e9 Do not try to update head of current branch 2021-03-08 15:29:27 +01:00
Michal Dorner
68792bf56a Allow single line filters 2021-03-08 15:24:06 +01:00
Michal Dorner
31c576896e Fix lastCommitCount has not been updated 2021-03-08 15:12:58 +01:00
Michal Dorner
3be8c93277 Fix fetching git history + fallback to unshallow repo 2021-03-08 15:09:07 +01:00
Michal Dorner
1cdd3bbdf6 Fix typo in README 2021-02-24 11:24:20 +01:00
Michal Dorner
e5b96fe4da Merge pull request #68 from dorny/list-files-csv
Add list-files: csv format
2021-02-20 11:32:11 +01:00
Michal Dorner
a339507743 Skip CI when modifying only README or CHANGELOG 2021-02-20 11:30:21 +01:00
Michal Dorner
febe8330ca Update README and CHANGELOG 2021-02-20 11:28:11 +01:00
Michal Dorner
b5fa2d5c02 Add list-files: csv format 2021-02-20 11:21:30 +01:00
Michal Dorner
e2bed85912 Mention test-reporter in README 2021-02-01 21:58:39 +01:00
Michal Dorner
7c0f15b688 Update CHANGELOG 2021-01-29 20:10:42 +01:00
Michal Dorner
cbc3287af3 Merge pull request #65 from dorny/add-count-output
Add count output
2021-01-29 20:03:56 +01:00
Michal Dorner
a2730492f0 Add test for ${FILTER_NAME}_count output 2021-01-29 19:59:09 +01:00
Michal Dorner
c2766acabb Add ${FILTER_NAME}_count output 2021-01-29 19:54:44 +01:00
Michal Dorner
363576b9ea Merge pull request #61 from tun0/fix-grouping
Fix grouping of changes
2021-01-26 21:31:41 +01:00
Michal Dorner
b1a097ef7b Rebuild dist/index.js 2021-01-26 21:28:06 +01:00
Ruben Laban
2c79a825c0 Fix grouping 2021-01-26 12:22:37 +01:00
12 changed files with 254 additions and 153 deletions

View File

@@ -1,6 +1,7 @@
name: "Build"
on:
push:
paths-ignore: [ '*.md' ]
branches:
- master

View File

@@ -1,6 +1,7 @@
name: "Pull Request Verification"
on:
pull_request:
paths-ignore: [ '*.md' ]
branches:
- master
- develop
@@ -90,6 +91,9 @@ jobs:
- name: filter-test
if: steps.filter.outputs.local != 'true'
run: exit 1
- name: count-test
if: steps.filter.outputs.local_count != 1
run: exit 1
test-change-type:
runs-on: ubuntu-latest

View File

@@ -1,5 +1,18 @@
# Changelog
## v2.9.2
- [Fix fetching git history](https://github.com/dorny/paths-filter/pull/75)
## v2.9.1
- [Fix fetching git history + fallback to unshallow repo](https://github.com/dorny/paths-filter/pull/74)
## v2.9.0
- [Add list-files: csv format](https://github.com/dorny/paths-filter/pull/68)
## v2.8.0
- [Add count output variable](https://github.com/dorny/paths-filter/pull/65)
- [Fix log grouping of changes](https://github.com/dorny/paths-filter/pull/61)
## v2.7.0
- [Add "changes" output variable to support matrix job configuration](https://github.com/dorny/paths-filter/pull/59)
- [Improved listing of matching files with `list-files: shell` and `list-files: escape` options](https://github.com/dorny/paths-filter/pull/58)

View File

@@ -66,13 +66,13 @@ For more scenarios see [examples](#examples) section.
# What's New
- Add `list-files: csv` format
- Configure matrix job to run for each folder with changes using `changes` output
- Improved listing of matching files with `list-files: shell` and `list-files: escape` options
- Support local changes
- Fixed retrieval of all changes via Github API when there are 100+ changes
- Paths expressions are now evaluated using [picomatch](https://github.com/micromatch/picomatch) library
- Support workflows triggered by any event
- Fixed compatibility with older (<2.23) versions of git
For more information see [CHANGELOG](https://github.com/dorny/paths-filter/blob/master/CHANGELOG.md)
@@ -117,11 +117,13 @@ For more information see [CHANGELOG](https://github.com/dorny/paths-filter/blob/
# is found or there are no more commits in the history.
# This option takes effect only when changes are detected
# using git against base branch (feature branch workflow).
# Default: 20
# Default: 100
initial-fetch-depth: ''
# Enables listing of files matching the filter:
# 'none' - Disables listing of matching files (default).
# 'csv' - Coma separated list of filenames.
# If needed it uses double quotes to wrap filename with unsafe characters.
# 'json' - Matching files paths are formatted as JSON array.
# 'shell' - Space delimited list usable as command line argument list in Linux shell.
# If needed it uses single or double quotes to wrap filename with unsafe characters.
@@ -147,6 +149,7 @@ For more information see [CHANGELOG](https://github.com/dorny/paths-filter/blob/
- For each filter it sets output variable named by the filter to the text:
- `'true'` - if **any** of changed files matches any of filter rules
- `'false'` - if **none** of changed files matches any of filter rules
- For each filter it sets output variable with name `${FILTER_NAME}_count` to the count of matching files.
- If enabled, for each filter it sets output variable with name `${FILTER_NAME}_files`. It will contain list of all files matching the filter.
- `changes` - JSON array with names of all filters matching any of changed files.
@@ -259,7 +262,7 @@ jobs:
matrix:
# Parse JSON array containing names of all filters matching any of changed files
# e.g. ['package1', 'package2'] if both package folders contains changes
package: ${{ fromJson(needs.changes.outputs.packages) }}
package: ${{ fromJSON(needs.changes.outputs.packages) }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
@@ -482,6 +485,8 @@ jobs:
```
</details>
# See also
- [test-reporter](https://github.com/dorny/test-reporter) - Displays test results from popular testing frameworks directly in GitHub
# License

View File

@@ -0,0 +1,23 @@
import {csvEscape} from '../src/list-format/csv-escape'
describe('csvEscape() backslash escapes every character except subset of definitely safe characters', () => {
test('simple filename should not be modified', () => {
expect(csvEscape('file.txt')).toBe('file.txt')
})
test('directory separator should be preserved and not escaped', () => {
expect(csvEscape('path/to/file.txt')).toBe('path/to/file.txt')
})
test('filename with spaces should be quoted', () => {
expect(csvEscape('file with space')).toBe('"file with space"')
})
test('filename with "," should be quoted', () => {
expect(csvEscape('file, with coma')).toBe('"file, with coma"')
})
test('Double quote should be escaped by another double quote', () => {
expect(csvEscape('file " with double quote')).toBe('"file "" with double quote"')
})
})

View File

@@ -1,24 +1,24 @@
import {escape, shellEscape} from '../src/shell-escape'
import {backslashEscape, shellEscape} from '../src/list-format/shell-escape'
describe('escape() backslash escapes every character except subset of definitely safe characters', () => {
test('simple filename should not be modified', () => {
expect(escape('file.txt')).toBe('file.txt')
expect(backslashEscape('file.txt')).toBe('file.txt')
})
test('directory separator should be preserved and not escaped', () => {
expect(escape('path/to/file.txt')).toBe('path/to/file.txt')
expect(backslashEscape('path/to/file.txt')).toBe('path/to/file.txt')
})
test('spaces should be escaped with backslash', () => {
expect(escape('file with space')).toBe('file\\ with\\ space')
expect(backslashEscape('file with space')).toBe('file\\ with\\ space')
})
test('quotes should be escaped with backslash', () => {
expect(escape('file\'with quote"')).toBe('file\\\'with\\ quote\\"')
expect(backslashEscape('file\'with quote"')).toBe('file\\\'with\\ quote\\"')
})
test('$variables should be escaped', () => {
expect(escape('$var')).toBe('\\$var')
expect(backslashEscape('$var')).toBe('\\$var')
})
})

View File

@@ -22,6 +22,8 @@ inputs:
description: |
Enables listing of files matching the filter:
'none' - Disables listing of matching files (default).
'csv' - Coma separated list of filenames.
If needed it uses double quotes to wrap filename with unsafe characters.
'json' - Serialized as JSON array.
'shell' - Space delimited list usable as command line argument list in linux shell.
If needed it uses single or double quotes to wrap filename with unsafe characters.
@@ -36,7 +38,7 @@ inputs:
until the merge-base is found or there are no more commits in the history.
This option takes effect only when changes are detected using git against different base branch.
required: false
default: '10'
default: '100'
outputs:
changes:
description: JSON array with names of all filters matching any of changed files

210
dist/index.js vendored
View File

@@ -3830,19 +3830,19 @@ async function getChangesInLastCommit() {
return parseGitDiffOutput(output);
}
exports.getChangesInLastCommit = getChangesInLastCommit;
async function getChanges(ref) {
if (!(await hasCommit(ref))) {
async function getChanges(baseRef) {
if (!(await hasCommit(baseRef))) {
// Fetch single commit
core.startGroup(`Fetching ${ref} from origin`);
await exec_1.default('git', ['fetch', '--depth=1', '--no-tags', 'origin', ref]);
core.startGroup(`Fetching ${baseRef} from origin`);
await exec_1.default('git', ['fetch', '--depth=1', '--no-tags', 'origin', baseRef]);
core.endGroup();
}
// Get differences between ref and HEAD
core.startGroup(`Change detection ${ref}..HEAD`);
core.startGroup(`Change detection ${baseRef}..HEAD`);
let output = '';
try {
// Two dots '..' change detection - directly compares two versions
output = (await exec_1.default('git', ['diff', '--no-renames', '--name-status', '-z', `${ref}..HEAD`])).stdout;
output = (await exec_1.default('git', ['diff', '--no-renames', '--name-status', '-z', `${baseRef}..HEAD`])).stdout;
}
finally {
fixStdOutNullTermination();
@@ -3865,47 +3865,48 @@ async function getChangesOnHead() {
return parseGitDiffOutput(output);
}
exports.getChangesOnHead = getChangesOnHead;
async function getChangesSinceMergeBase(ref, initialFetchDepth) {
if (!(await hasCommit(ref))) {
// Fetch and add base branch
core.startGroup(`Fetching ${ref}`);
try {
await exec_1.default('git', ['fetch', `--depth=${initialFetchDepth}`, '--no-tags', 'origin', `${ref}:${ref}`]);
}
finally {
core.endGroup();
}
}
async function getChangesSinceMergeBase(base, ref, initialFetchDepth) {
const baseRef = `remotes/origin/${base}`;
async function hasMergeBase() {
return (await exec_1.default('git', ['merge-base', ref, 'HEAD'], { ignoreReturnCode: true })).code === 0;
return (await exec_1.default('git', ['merge-base', baseRef, ref], { ignoreReturnCode: true })).code === 0;
}
async function countCommits() {
return (await getNumberOfCommits('HEAD')) + (await getNumberOfCommits(ref));
}
core.startGroup(`Searching for merge-base with ${ref}`);
// Fetch more commits until merge-base is found
if (!(await hasMergeBase())) {
let deepen = initialFetchDepth;
let lastCommitsCount = await countCommits();
do {
await exec_1.default('git', ['fetch', `--deepen=${deepen}`, '--no-tags']);
const count = await countCommits();
if (count <= lastCommitsCount) {
core.info('No merge base found - all files will be listed as added');
core.endGroup();
return await listAllFilesAsAdded();
let noMergeBase = false;
core.startGroup(`Searching for merge-base ${baseRef}...${ref}`);
try {
if (!(await hasMergeBase())) {
await exec_1.default('git', ['fetch', `--depth=${initialFetchDepth}`, 'origin', base, ref]);
let depth = initialFetchDepth;
let lastCommitCount = await getCommitCount();
while (!(await hasMergeBase())) {
depth = Math.min(depth * 2, Number.MAX_SAFE_INTEGER);
await exec_1.default('git', ['fetch', `--deepen=${depth}`, 'origin', base, ref]);
const commitCount = await getCommitCount();
if (commitCount === lastCommitCount) {
core.info('No more commits were fetched');
core.info('Last attempt will be to fetch full history');
await exec_1.default('git', ['fetch']);
if (!(await hasMergeBase())) {
noMergeBase = true;
}
break;
}
lastCommitCount = commitCount;
}
lastCommitsCount = count;
deepen = Math.min(deepen * 2, Number.MAX_SAFE_INTEGER);
} while (!(await hasMergeBase()));
}
}
finally {
core.endGroup();
}
if (noMergeBase) {
core.warning('No merge base found - all files will be listed as added');
return await listAllFilesAsAdded();
}
core.endGroup();
// Get changes introduced on HEAD compared to ref
core.startGroup(`Change detection ${ref}...HEAD`);
core.startGroup(`Change detection ${baseRef}...${ref}`);
let output = '';
try {
// Three dots '...' change detection - finds merge-base and compares against it
output = (await exec_1.default('git', ['diff', '--no-renames', '--name-status', '-z', `${ref}...HEAD`])).stdout;
output = (await exec_1.default('git', ['diff', '--no-renames', '--name-status', '-z', `${baseRef}...${ref}`])).stdout;
}
finally {
fixStdOutNullTermination();
@@ -3956,7 +3957,7 @@ async function getCurrentRef() {
if (describe.code === 0) {
return describe.stdout.trim();
}
return (await exec_1.default('git', ['rev-parse', 'HEAD'])).stdout.trim();
return (await exec_1.default('git', ['rev-parse', exports.HEAD])).stdout.trim();
}
finally {
core.endGroup();
@@ -3988,8 +3989,8 @@ async function hasCommit(ref) {
core.endGroup();
}
}
async function getNumberOfCommits(ref) {
const output = (await exec_1.default('git', ['rev-list', `--count`, ref])).stdout;
async function getCommitCount() {
const output = (await exec_1.default('git', ['rev-list', '--count', '--all'])).stdout;
const count = parseInt(output);
return isNaN(count) ? 0 : count;
}
@@ -4648,7 +4649,8 @@ const github = __importStar(__webpack_require__(469));
const filter_1 = __webpack_require__(235);
const file_1 = __webpack_require__(258);
const git = __importStar(__webpack_require__(136));
const shell_escape_1 = __webpack_require__(751);
const shell_escape_1 = __webpack_require__(206);
const csv_escape_1 = __webpack_require__(410);
async function run() {
try {
const workingDirectory = core.getInput('working-directory', { required: false });
@@ -4675,7 +4677,7 @@ async function run() {
}
}
function isPathInput(text) {
return !text.includes('\n');
return !(text.includes('\n') || text.includes(':'));
}
function getConfigFileContent(configPath) {
if (!fs.existsSync(configPath)) {
@@ -4708,7 +4710,7 @@ async function getChangedFilesFromGit(base, initialFetchDepth) {
var _a;
const defaultRef = (_a = github.context.payload.repository) === null || _a === void 0 ? void 0 : _a.default_branch;
const beforeSha = github.context.eventName === 'push' ? github.context.payload.before : null;
const pushRef = git.getShortName(github.context.ref) ||
const ref = git.getShortName(github.context.ref) ||
(core.warning(`'ref' field is missing in event payload - using current branch, tag or commit SHA`),
await git.getCurrentRef());
const baseRef = git.getShortName(base) || defaultRef;
@@ -4716,10 +4718,10 @@ async function getChangedFilesFromGit(base, initialFetchDepth) {
throw new Error("This action requires 'base' input to be configured or 'repository.default_branch' to be set in the event payload");
}
const isBaseRefSha = git.isGitSha(baseRef);
const isBaseSameAsPush = baseRef === pushRef;
const isBaseRefSameAsRef = baseRef === ref;
// If base is commit SHA we will do comparison against the referenced commit
// Or if base references same branch it was pushed to, we will do comparison against the previously pushed commit
if (isBaseRefSha || isBaseSameAsPush) {
if (isBaseRefSha || isBaseRefSameAsRef) {
if (!isBaseRefSha && !beforeSha) {
core.warning(`'before' field is missing in event payload - changes will be detected from last commit`);
return await git.getChangesInLastCommit();
@@ -4730,7 +4732,7 @@ async function getChangedFilesFromGit(base, initialFetchDepth) {
if (baseSha === git.NULL_SHA) {
if (defaultRef && baseRef !== defaultRef) {
core.info(`First push of a branch detected - changes will be detected against the default branch ${defaultRef}`);
return await git.getChangesSinceMergeBase(defaultRef, initialFetchDepth);
return await git.getChangesSinceMergeBase(defaultRef, ref, initialFetchDepth);
}
else {
core.info('Initial push detected - all files will be listed as added');
@@ -4742,7 +4744,7 @@ async function getChangedFilesFromGit(base, initialFetchDepth) {
}
// Changes introduced by current branch against the base branch
core.info(`Changes will be detected against the branch ${baseRef}`);
return await git.getChangesSinceMergeBase(baseRef, initialFetchDepth);
return await git.getChangesSinceMergeBase(baseRef, ref, initialFetchDepth);
}
// Uses github REST api to get list of files changed in PR
async function getChangedFilesFromApi(token, pullRequest) {
@@ -4806,10 +4808,12 @@ function exportResults(results, format) {
core.info('Matching files: none');
}
core.setOutput(key, value);
core.setOutput(`${key}_count`, files.length);
if (format !== 'none') {
const filesValue = serializeExport(files, format);
core.setOutput(`${key}_files`, filesValue);
}
core.endGroup();
}
if (results['changes'] === undefined) {
const changesJson = JSON.stringify(changes);
@@ -4819,15 +4823,16 @@ function exportResults(results, format) {
else {
core.info('Cannot set changes output variable - name already used by filter output');
}
core.endGroup();
}
function serializeExport(files, format) {
const fileNames = files.map(file => file.filename);
switch (format) {
case 'csv':
return fileNames.map(csv_escape_1.csvEscape).join(',');
case 'json':
return JSON.stringify(fileNames);
case 'escape':
return fileNames.map(shell_escape_1.escape).join(' ');
return fileNames.map(shell_escape_1.backslashEscape).join(' ');
case 'shell':
return fileNames.map(shell_escape_1.shellEscape).join(' ');
default:
@@ -4835,7 +4840,7 @@ function serializeExport(files, format) {
}
}
function isExportFormat(value) {
return value === 'none' || value === 'shell' || value === 'json' || value === 'escape';
return ['none', 'csv', 'shell', 'json', 'escape'].includes(value);
}
run();
@@ -5027,6 +5032,43 @@ module.exports = {
};
/***/ }),
/***/ 206:
/***/ (function(__unusedmodule, exports) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.shellEscape = exports.backslashEscape = void 0;
// Backslash escape every character except small subset of definitely safe characters
function backslashEscape(value) {
return value.replace(/([^a-zA-Z0-9,._+:@%/-])/gm, '\\$1');
}
exports.backslashEscape = backslashEscape;
// Returns filename escaped for usage as shell argument.
// Applies "human readable" approach with as few escaping applied as possible
function shellEscape(value) {
if (value === '')
return value;
// Only safe characters
if (/^[a-zA-Z0-9,._+:@%/-]+$/m.test(value)) {
return value;
}
if (value.includes("'")) {
// Only safe characters, single quotes and white-spaces
if (/^[a-zA-Z0-9,._+:@%/'\s-]+$/m.test(value)) {
return `"${value}"`;
}
// Split by single quote and apply escaping recursively
return value.split("'").map(shellEscape).join("\\'");
}
// Contains some unsafe characters but no single quote
return `'${value}'`;
}
exports.shellEscape = shellEscape;
/***/ }),
/***/ 211:
@@ -8812,6 +8854,33 @@ function Octokit(plugins, options) {
}
/***/ }),
/***/ 410:
/***/ (function(__unusedmodule, exports) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.csvEscape = void 0;
// Returns filename escaped for CSV
// Wraps file name into "..." only when it contains some potentially unsafe character
function csvEscape(value) {
if (value === '')
return value;
// Only safe characters
if (/^[a-zA-Z0-9._+:@%/-]+$/m.test(value)) {
return value;
}
// https://tools.ietf.org/html/rfc4180
// If double-quotes are used to enclose fields, then a double-quote
// appearing inside a field must be escaped by preceding it with
// another double quote
return `"${value.replace(/"/g, '""')}"`;
}
exports.csvEscape = csvEscape;
/***/ }),
/***/ 413:
@@ -15224,43 +15293,6 @@ function sync (path, options) {
module.exports = require("fs");
/***/ }),
/***/ 751:
/***/ (function(__unusedmodule, exports) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.shellEscape = exports.escape = void 0;
// Backslash escape every character except small subset of definitely safe characters
function escape(value) {
return value.replace(/([^a-zA-Z0-9,._+:@%/-])/gm, '\\$1');
}
exports.escape = escape;
// Returns filename escaped for usage as shell argument.
// Applies "human readable" approach with as few escaping applied as possible
function shellEscape(value) {
if (value === '')
return value;
// Only safe characters
if (/^[a-zA-Z0-9,._+:@%/-]+$/m.test(value)) {
return value;
}
if (value.includes("'")) {
// Only safe characters, single quotes and white-spaces
if (/^[a-zA-Z0-9,._+:@%/'\s-]+$/m.test(value)) {
return `"${value}"`;
}
// Split by single quote and apply escaping recursively
return value.split("'").map(shellEscape).join("\\'");
}
// Contains some unsafe characters but no single quote
return `'${value}'`;
}
exports.shellEscape = shellEscape;
/***/ }),
/***/ 753:

View File

@@ -18,20 +18,20 @@ export async function getChangesInLastCommit(): Promise<File[]> {
return parseGitDiffOutput(output)
}
export async function getChanges(ref: string): Promise<File[]> {
if (!(await hasCommit(ref))) {
export async function getChanges(baseRef: string): Promise<File[]> {
if (!(await hasCommit(baseRef))) {
// Fetch single commit
core.startGroup(`Fetching ${ref} from origin`)
await exec('git', ['fetch', '--depth=1', '--no-tags', 'origin', ref])
core.startGroup(`Fetching ${baseRef} from origin`)
await exec('git', ['fetch', '--depth=1', '--no-tags', 'origin', baseRef])
core.endGroup()
}
// Get differences between ref and HEAD
core.startGroup(`Change detection ${ref}..HEAD`)
core.startGroup(`Change detection ${baseRef}..HEAD`)
let output = ''
try {
// Two dots '..' change detection - directly compares two versions
output = (await exec('git', ['diff', '--no-renames', '--name-status', '-z', `${ref}..HEAD`])).stdout
output = (await exec('git', ['diff', '--no-renames', '--name-status', '-z', `${baseRef}..HEAD`])).stdout
} finally {
fixStdOutNullTermination()
core.endGroup()
@@ -54,50 +54,51 @@ export async function getChangesOnHead(): Promise<File[]> {
return parseGitDiffOutput(output)
}
export async function getChangesSinceMergeBase(ref: string, initialFetchDepth: number): Promise<File[]> {
if (!(await hasCommit(ref))) {
// Fetch and add base branch
core.startGroup(`Fetching ${ref}`)
try {
await exec('git', ['fetch', `--depth=${initialFetchDepth}`, '--no-tags', 'origin', `${ref}:${ref}`])
} finally {
core.endGroup()
}
}
export async function getChangesSinceMergeBase(base: string, ref: string, initialFetchDepth: number): Promise<File[]> {
const baseRef = `remotes/origin/${base}`
async function hasMergeBase(): Promise<boolean> {
return (await exec('git', ['merge-base', ref, 'HEAD'], {ignoreReturnCode: true})).code === 0
return (await exec('git', ['merge-base', baseRef, ref], {ignoreReturnCode: true})).code === 0
}
async function countCommits(): Promise<number> {
return (await getNumberOfCommits('HEAD')) + (await getNumberOfCommits(ref))
}
core.startGroup(`Searching for merge-base with ${ref}`)
// Fetch more commits until merge-base is found
if (!(await hasMergeBase())) {
let deepen = initialFetchDepth
let lastCommitsCount = await countCommits()
do {
await exec('git', ['fetch', `--deepen=${deepen}`, '--no-tags'])
const count = await countCommits()
if (count <= lastCommitsCount) {
core.info('No merge base found - all files will be listed as added')
core.endGroup()
return await listAllFilesAsAdded()
let noMergeBase = false
core.startGroup(`Searching for merge-base ${baseRef}...${ref}`)
try {
if (!(await hasMergeBase())) {
await exec('git', ['fetch', `--depth=${initialFetchDepth}`, 'origin', base, ref])
let depth = initialFetchDepth
let lastCommitCount = await getCommitCount()
while (!(await hasMergeBase())) {
depth = Math.min(depth * 2, Number.MAX_SAFE_INTEGER)
await exec('git', ['fetch', `--deepen=${depth}`, 'origin', base, ref])
const commitCount = await getCommitCount()
if (commitCount === lastCommitCount) {
core.info('No more commits were fetched')
core.info('Last attempt will be to fetch full history')
await exec('git', ['fetch'])
if (!(await hasMergeBase())) {
noMergeBase = true
}
break
}
lastCommitCount = commitCount
}
lastCommitsCount = count
deepen = Math.min(deepen * 2, Number.MAX_SAFE_INTEGER)
} while (!(await hasMergeBase()))
}
} finally {
core.endGroup()
}
if (noMergeBase) {
core.warning('No merge base found - all files will be listed as added')
return await listAllFilesAsAdded()
}
core.endGroup()
// Get changes introduced on HEAD compared to ref
core.startGroup(`Change detection ${ref}...HEAD`)
core.startGroup(`Change detection ${baseRef}...${ref}`)
let output = ''
try {
// Three dots '...' change detection - finds merge-base and compares against it
output = (await exec('git', ['diff', '--no-renames', '--name-status', '-z', `${ref}...HEAD`])).stdout
output = (await exec('git', ['diff', '--no-renames', '--name-status', '-z', `${baseRef}...${ref}`])).stdout
} finally {
fixStdOutNullTermination()
core.endGroup()
@@ -150,7 +151,7 @@ export async function getCurrentRef(): Promise<string> {
return describe.stdout.trim()
}
return (await exec('git', ['rev-parse', 'HEAD'])).stdout.trim()
return (await exec('git', ['rev-parse', HEAD])).stdout.trim()
} finally {
core.endGroup()
}
@@ -181,8 +182,8 @@ async function hasCommit(ref: string): Promise<boolean> {
}
}
async function getNumberOfCommits(ref: string): Promise<number> {
const output = (await exec('git', ['rev-list', `--count`, ref])).stdout
async function getCommitCount(): Promise<number> {
const output = (await exec('git', ['rev-list', '--count', '--all'])).stdout
const count = parseInt(output)
return isNaN(count) ? 0 : count
}

View File

@@ -0,0 +1,16 @@
// Returns filename escaped for CSV
// Wraps file name into "..." only when it contains some potentially unsafe character
export function csvEscape(value: string): string {
if (value === '') return value
// Only safe characters
if (/^[a-zA-Z0-9._+:@%/-]+$/m.test(value)) {
return value
}
// https://tools.ietf.org/html/rfc4180
// If double-quotes are used to enclose fields, then a double-quote
// appearing inside a field must be escaped by preceding it with
// another double quote
return `"${value.replace(/"/g, '""')}"`
}

View File

@@ -1,5 +1,5 @@
// Backslash escape every character except small subset of definitely safe characters
export function escape(value: string): string {
export function backslashEscape(value: string): string {
return value.replace(/([^a-zA-Z0-9,._+:@%/-])/gm, '\\$1')
}

View File

@@ -6,9 +6,10 @@ import {Webhooks} from '@octokit/webhooks'
import {Filter, FilterResults} from './filter'
import {File, ChangeStatus} from './file'
import * as git from './git'
import {escape, shellEscape} from './shell-escape'
import {backslashEscape, shellEscape} from './list-format/shell-escape'
import {csvEscape} from './list-format/csv-escape'
type ExportFormat = 'none' | 'json' | 'shell' | 'escape'
type ExportFormat = 'none' | 'csv' | 'json' | 'shell' | 'escape'
async function run(): Promise<void> {
try {
@@ -39,7 +40,7 @@ async function run(): Promise<void> {
}
function isPathInput(text: string): boolean {
return !text.includes('\n')
return !(text.includes('\n') || text.includes(':'))
}
function getConfigFileContent(configPath: string): string {
@@ -79,7 +80,7 @@ async function getChangedFilesFromGit(base: string, initialFetchDepth: number):
const beforeSha =
github.context.eventName === 'push' ? (github.context.payload as Webhooks.WebhookPayloadPush).before : null
const pushRef =
const ref =
git.getShortName(github.context.ref) ||
(core.warning(`'ref' field is missing in event payload - using current branch, tag or commit SHA`),
await git.getCurrentRef())
@@ -92,11 +93,11 @@ async function getChangedFilesFromGit(base: string, initialFetchDepth: number):
}
const isBaseRefSha = git.isGitSha(baseRef)
const isBaseSameAsPush = baseRef === pushRef
const isBaseRefSameAsRef = baseRef === ref
// If base is commit SHA we will do comparison against the referenced commit
// Or if base references same branch it was pushed to, we will do comparison against the previously pushed commit
if (isBaseRefSha || isBaseSameAsPush) {
if (isBaseRefSha || isBaseRefSameAsRef) {
if (!isBaseRefSha && !beforeSha) {
core.warning(`'before' field is missing in event payload - changes will be detected from last commit`)
return await git.getChangesInLastCommit()
@@ -108,7 +109,7 @@ async function getChangedFilesFromGit(base: string, initialFetchDepth: number):
if (baseSha === git.NULL_SHA) {
if (defaultRef && baseRef !== defaultRef) {
core.info(`First push of a branch detected - changes will be detected against the default branch ${defaultRef}`)
return await git.getChangesSinceMergeBase(defaultRef, initialFetchDepth)
return await git.getChangesSinceMergeBase(defaultRef, ref, initialFetchDepth)
} else {
core.info('Initial push detected - all files will be listed as added')
return await git.listAllFilesAsAdded()
@@ -121,7 +122,7 @@ async function getChangedFilesFromGit(base: string, initialFetchDepth: number):
// Changes introduced by current branch against the base branch
core.info(`Changes will be detected against the branch ${baseRef}`)
return await git.getChangesSinceMergeBase(baseRef, initialFetchDepth)
return await git.getChangesSinceMergeBase(baseRef, ref, initialFetchDepth)
}
// Uses github REST api to get list of files changed in PR
@@ -190,10 +191,12 @@ function exportResults(results: FilterResults, format: ExportFormat): void {
}
core.setOutput(key, value)
core.setOutput(`${key}_count`, files.length)
if (format !== 'none') {
const filesValue = serializeExport(files, format)
core.setOutput(`${key}_files`, filesValue)
}
core.endGroup()
}
if (results['changes'] === undefined) {
@@ -203,16 +206,17 @@ function exportResults(results: FilterResults, format: ExportFormat): void {
} else {
core.info('Cannot set changes output variable - name already used by filter output')
}
core.endGroup()
}
function serializeExport(files: File[], format: ExportFormat): string {
const fileNames = files.map(file => file.filename)
switch (format) {
case 'csv':
return fileNames.map(csvEscape).join(',')
case 'json':
return JSON.stringify(fileNames)
case 'escape':
return fileNames.map(escape).join(' ')
return fileNames.map(backslashEscape).join(' ')
case 'shell':
return fileNames.map(shellEscape).join(' ')
default:
@@ -221,7 +225,7 @@ function serializeExport(files: File[], format: ExportFormat): string {
}
function isExportFormat(value: string): value is ExportFormat {
return value === 'none' || value === 'shell' || value === 'json' || value === 'escape'
return ['none', 'csv', 'shell', 'json', 'escape'].includes(value)
}
run()