Collect and report number of changes

This commit is contained in:
Boris Lykah 2022-02-20 17:56:44 -07:00 committed by Rebecca Turner
parent de90cc6fb3
commit 28cec18b46
No known key found for this signature in database
11 changed files with 374 additions and 169 deletions

View File

@ -1,5 +1,8 @@
# Changelog
# v3.0.3
- Added stat feature which computes diffs in subtrees
## v3.0.2
- [Add config parameter for predicate quantifier](https://github.com/dorny/paths-filter/pull/224)

View File

@ -1,4 +1,4 @@
# Paths Changes Filter
# Paths Changes Filter And Diff Stat
[GitHub Action](https://github.com/features/actions) that enables conditional execution of workflow steps and jobs, based on the files modified by pull request, on a feature
branch, or by the recently pushed commits.
@ -73,6 +73,7 @@ For more scenarios see [examples](#examples) section.
## What's New
- New major release `v3` after update to Node 20 [Breaking change]
- Add `stat` parameter that enables output of the file changes statistics per filter.
- Add `ref` input parameter
- Add `list-files: csv` format
- Configure matrix job to run for each folder with changes using `changes` output
@ -154,14 +155,14 @@ For more information, see [CHANGELOG](https://github.com/dorny/paths-filter/blob
# Default: ${{ github.token }}
token: ''
# Optional parameter to override the default behavior of file matching algorithm.
# 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,
# 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:
@ -179,6 +180,7 @@ For more information, see [CHANGELOG](https://github.com/dorny/paths-filter/blob
- For each filter, it 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.
- If `stat` input is set to an output format, the output variable `stat` contains JSON or CSV value with the change statistics for each filter.
## Examples
@ -558,10 +560,37 @@ jobs:
</details>
<details>
<summary>Passing number of added lines from a filter to another action</summary>
```yaml
- uses: dorny/paths-filter@v2
id: filter
with:
# Enable listing of diff stat matching each filter.
# Paths to files will be available in `stat` output variable.
# Stat will be formatted as JSON object
stat: json
# In this example all changed files are passed to the following action to do
# some custom processing.
filters: |
changed:
- '**'
- name: Lint Markdown
uses: johndoe/some-action@v1
# Run action only if the change is large enough.
if: ${{fromJson(steps.filter.outputs.stat).changed.additionCount > 1000}}
with:
files: ${{ steps.filter.outputs.changed_files }}
```
</details>
## See also
- [test-reporter](https://github.com/dorny/test-reporter) - Displays test results from popular testing frameworks directly in GitHub
## License
The scripts and documentation in this project are released under the [MIT License](https://github.com/dorny/paths-filter/blob/master/LICENSE)
The scripts and documentation in this project are released under the [MIT License](https://github.com/lykahb/paths-filter/blob/master/LICENSE)

View File

@ -181,7 +181,7 @@ describe('matching specific change status', () => {
- added: "**/*"
`
let filter = new Filter(yaml)
const files = [{status: ChangeStatus.Added, filename: 'file.js'}]
const files = [{status: ChangeStatus.Added, filename: 'file.js', additions: 1, deletions: 0}]
const match = filter.match(files)
expect(match.add).toEqual(files)
})
@ -192,7 +192,7 @@ describe('matching specific change status', () => {
- added|modified: "**/*"
`
let filter = new Filter(yaml)
const files = [{status: ChangeStatus.Modified, filename: 'file.js'}]
const files = [{status: ChangeStatus.Modified, filename: 'file.js', additions: 1, deletions: 1}]
const match = filter.match(files)
expect(match.addOrModify).toEqual(files)
})
@ -214,12 +214,6 @@ describe('matching specific change status', () => {
function modified(paths: string[]): File[] {
return paths.map(filename => {
return {filename, status: ChangeStatus.Modified}
})
}
function renamed(paths: string[]): File[] {
return paths.map(filename => {
return {filename, status: ChangeStatus.Renamed}
return {filename, status: ChangeStatus.Modified, additions: 1, deletions: 1}
})
}

View File

@ -2,8 +2,8 @@ import * as git from '../src/git'
import {ChangeStatus} from '../src/file'
describe('parsing output of the git diff command', () => {
test('parseGitDiffOutput returns files with correct change status', async () => {
const files = git.parseGitDiffOutput(
test('parseGitDiffNameStatusOutput returns files with correct change status', async () => {
const files = git.parseGitDiffNameStatusOutput(
'A\u0000LICENSE\u0000' + 'M\u0000src/index.ts\u0000' + 'D\u0000src/main.ts\u0000'
)
expect(files.length).toBe(3)
@ -14,6 +14,17 @@ describe('parsing output of the git diff command', () => {
expect(files[2].filename).toBe('src/main.ts')
expect(files[2].status).toBe(ChangeStatus.Deleted)
})
test('parseGitDiffNumstatOutput returns files with correct change status', async () => {
const files = git.parseGitDiffNumstatOutput('4\t2\tLICENSE\u0000' + '5\t0\tsrc/index.ts\u0000')
expect(files.length).toBe(2)
expect(files[0].filename).toBe('LICENSE')
expect(files[0].additions).toBe(4)
expect(files[0].deletions).toBe(2)
expect(files[1].filename).toBe('src/index.ts')
expect(files[1].additions).toBe(5)
expect(files[1].deletions).toBe(0)
})
})
describe('git utility function tests (those not invoking git)', () => {

View File

@ -1,6 +1,6 @@
name: 'Paths Changes Filter'
name: 'Paths Changes Filter And Diff Stat'
description: 'Execute your workflow steps only if relevant files are modified.'
author: 'Michal Dorner <dorner.michal@gmail.com>'
author: 'Michal Dorner <dorner.michal@gmail.com>, Boris Lykah<lykahb@gmail.com>'
inputs:
token:
description: 'GitHub Access Token'
@ -36,6 +36,16 @@ inputs:
Backslash escapes every potentially unsafe character.
required: false
default: none
stat:
description: |
Enables listing of that enables output of the file change statistics per filter, similar to `git diff --shortstat`.
If some changes do not match any filter, the output includes an additional entry with the filter name 'other'.
'none' - Disables listing of stats (default).
'csv' - Coma separated list that has name of filter, count of additions, count of deletions, count of changed files.
If needed it uses double quotes to wrap name of filter with unsafe characters. For example, `"some filter",12,7,2`.
'json' - Serialized as JSON object where the filter names are keys. For example, `{"some filter": {"additionCount": 12, "deletionCount": 7, "fileCount": 2}}`
required: false
default: none
initial-fetch-depth:
description: |
How many commits are initially fetched from base branch.

205
dist/index.js vendored
View File

@ -123,6 +123,11 @@ class Filter {
for (const [key, patterns] of Object.entries(this.rules)) {
result[key] = files.filter(file => this.isMatch(file, patterns));
}
if (!this.rules.hasOwnProperty('other')) {
const matchingFilenamesList = Object.values(result).flatMap(filteredFiles => filteredFiles.map(file => file.filename));
const matchingFilenamesSet = new Set(matchingFilenamesList);
result.other = files.filter(file => !matchingFilenamesSet.has(file.filename));
}
return result;
}
isMatch(file, patterns) {
@ -204,55 +209,39 @@ var __importStar = (this && this.__importStar) || function (mod) {
return result;
};
Object.defineProperty(exports, "__esModule", ({ value: true }));
exports.isGitSha = exports.getShortName = exports.getCurrentRef = exports.listAllFilesAsAdded = exports.parseGitDiffOutput = exports.getChangesSinceMergeBase = exports.getChangesOnHead = exports.getChanges = exports.getChangesInLastCommit = exports.HEAD = exports.NULL_SHA = void 0;
exports.isGitSha = exports.getShortName = exports.getCurrentRef = exports.listAllFilesAsAdded = exports.parseGitDiffNumstatOutput = exports.getGitDiffStatusNumstat = exports.parseGitDiffNameStatusOutput = exports.getChangesSinceMergeBase = exports.getChangesOnHead = exports.getChanges = exports.getChangesInLastCommit = exports.HEAD = exports.NULL_SHA = void 0;
const exec_1 = __nccwpck_require__(1514);
const core = __importStar(__nccwpck_require__(2186));
const file_1 = __nccwpck_require__(4014);
exports.NULL_SHA = '0000000000000000000000000000000000000000';
exports.HEAD = 'HEAD';
async function getChangesInLastCommit() {
core.startGroup(`Change detection in last commit`);
let output = '';
try {
output = (await (0, exec_1.getExecOutput)('git', ['log', '--format=', '--no-renames', '--name-status', '-z', '-n', '1'])).stdout;
}
finally {
fixStdOutNullTermination();
core.endGroup();
}
return parseGitDiffOutput(output);
return core.group(`Change detection in last commit`, async () => {
try {
// Calling git log on the last commit works when only the last commit may be checked out. Calling git diff HEAD^..HEAD needs two commits.
const statusOutput = (await (0, exec_1.getExecOutput)('git', ['log', '--format=', '--no-renames', '--name-status', '-z', '-n', '1'])).stdout;
const numstatOutput = (await (0, exec_1.getExecOutput)('git', ['log', '--format=', '--no-renames', '--numstat', '-z', '-n', '1'])).stdout;
const statusFiles = parseGitDiffNameStatusOutput(statusOutput);
const numstatFiles = parseGitDiffNumstatOutput(numstatOutput);
return mergeStatusNumstat(statusFiles, numstatFiles);
}
finally {
fixStdOutNullTermination();
}
});
}
exports.getChangesInLastCommit = getChangesInLastCommit;
async function getChanges(base, head) {
const baseRef = await ensureRefAvailable(base);
const headRef = await ensureRefAvailable(head);
// Get differences between ref and HEAD
core.startGroup(`Change detection ${base}..${head}`);
let output = '';
try {
// Two dots '..' change detection - directly compares two versions
output = (await (0, exec_1.getExecOutput)('git', ['diff', '--no-renames', '--name-status', '-z', `${baseRef}..${headRef}`]))
.stdout;
}
finally {
fixStdOutNullTermination();
core.endGroup();
}
return parseGitDiffOutput(output);
// Two dots '..' change detection - directly compares two versions
return core.group(`Change detection ${base}..${head}`, () => getGitDiffStatusNumstat(`${baseRef}..${headRef}`));
}
exports.getChanges = getChanges;
async function getChangesOnHead() {
// Get current changes - both staged and unstaged
core.startGroup(`Change detection on HEAD`);
let output = '';
try {
output = (await (0, exec_1.getExecOutput)('git', ['diff', '--no-renames', '--name-status', '-z', 'HEAD'])).stdout;
}
finally {
fixStdOutNullTermination();
core.endGroup();
}
return parseGitDiffOutput(output);
return core.group(`Change detection on HEAD`, () => getGitDiffStatusNumstat(`HEAD`));
}
exports.getChangesOnHead = getChangesOnHead;
async function getChangesSinceMergeBase(base, head, initialFetchDepth) {
@ -317,19 +306,30 @@ async function getChangesSinceMergeBase(base, head, initialFetchDepth) {
diffArg = `${baseRef}..${headRef}`;
}
// Get changes introduced on ref compared to base
core.startGroup(`Change detection ${diffArg}`);
return getGitDiffStatusNumstat(diffArg);
}
exports.getChangesSinceMergeBase = getChangesSinceMergeBase;
async function gitDiffNameStatus(diffArg) {
let output = '';
try {
output = (await (0, exec_1.getExecOutput)('git', ['diff', '--no-renames', '--name-status', '-z', diffArg])).stdout;
}
finally {
fixStdOutNullTermination();
core.endGroup();
}
return parseGitDiffOutput(output);
return output;
}
exports.getChangesSinceMergeBase = getChangesSinceMergeBase;
function parseGitDiffOutput(output) {
async function gitDiffNumstat(diffArg) {
let output = '';
try {
output = (await (0, exec_1.getExecOutput)('git', ['diff', '--no-renames', '--numstat', '-z', diffArg])).stdout;
}
finally {
fixStdOutNullTermination();
}
return output;
}
function parseGitDiffNameStatusOutput(output) {
const tokens = output.split('\u0000').filter(s => s.length > 0);
const files = [];
for (let i = 0; i + 1 < tokens.length; i += 2) {
@ -340,24 +340,44 @@ function parseGitDiffOutput(output) {
}
return files;
}
exports.parseGitDiffOutput = parseGitDiffOutput;
exports.parseGitDiffNameStatusOutput = parseGitDiffNameStatusOutput;
function mergeStatusNumstat(statusEntries, numstatEntries) {
const statusMap = {};
statusEntries.forEach(f => (statusMap[f.filename] = f));
return numstatEntries.map(f => {
const status = statusMap[f.filename];
if (!status) {
throw new Error(`Cannot find the status entry for file: ${f.filename}`);
}
return { ...f, status: status.status };
});
}
async function getGitDiffStatusNumstat(diffArg) {
const statusFiles = await gitDiffNameStatus(diffArg).then(parseGitDiffNameStatusOutput);
const numstatFiles = await gitDiffNumstat(diffArg).then(parseGitDiffNumstatOutput);
return mergeStatusNumstat(statusFiles, numstatFiles);
}
exports.getGitDiffStatusNumstat = getGitDiffStatusNumstat;
function parseGitDiffNumstatOutput(output) {
const rows = output.split('\u0000').filter(s => s.length > 0);
return rows.map(row => {
const tokens = row.split('\t');
// For the binary files set the numbers to zero. This matches the response of Github API.
const additions = tokens[0] == '-' ? 0 : Number.parseInt(tokens[0]);
const deletions = tokens[1] == '-' ? 0 : Number.parseInt(tokens[1]);
return {
filename: tokens[2],
additions,
deletions
};
});
}
exports.parseGitDiffNumstatOutput = parseGitDiffNumstatOutput;
async function listAllFilesAsAdded() {
core.startGroup('Listing all files tracked by git');
let output = '';
try {
output = (await (0, exec_1.getExecOutput)('git', ['ls-files', '-z'])).stdout;
}
finally {
fixStdOutNullTermination();
core.endGroup();
}
return output
.split('\u0000')
.filter(s => s.length > 0)
.map(path => ({
status: file_1.ChangeStatus.Added,
filename: path
}));
return core.group(`Listing all files tracked by git`, async () => {
const emptyTreeHash = (await (0, exec_1.getExecOutput)('git', ['hash-object', '-t', 'tree', '/dev/null'])).stdout;
return getGitDiffStatusNumstat(emptyTreeHash);
});
}
exports.listAllFilesAsAdded = listAllFilesAsAdded;
async function getCurrentRef() {
@ -572,11 +592,16 @@ async function run() {
const base = core.getInput('base', { required: false });
const filtersInput = core.getInput('filters', { required: true });
const filtersYaml = isPathInput(filtersInput) ? getConfigFileContent(filtersInput) : filtersInput;
const listFiles = core.getInput('list-files', { required: false }).toLowerCase() || 'none';
const listFilesFormat = core.getInput('list-files', { required: false }).toLowerCase() || 'none';
const statFormat = core.getInput('stat', { required: false }).toLowerCase() || 'none';
const initialFetchDepth = parseInt(core.getInput('initial-fetch-depth', { required: false })) || 10;
const predicateQuantifier = core.getInput('predicate-quantifier', { required: false }) || filter_1.PredicateQuantifier.SOME;
if (!isExportFormat(listFiles)) {
core.setFailed(`Input parameter 'list-files' is set to invalid value '${listFiles}'`);
if (!isFilesExportFormat(listFilesFormat)) {
core.setFailed(`Input parameter 'list-files' is set to invalid value '${listFilesFormat}'`);
return;
}
if (!isStatExportFormat(statFormat)) {
core.setFailed(`Input parameter 'stat' is set to invalid value '${statFormat}'`);
return;
}
if (!(0, filter_1.isPredicateQuantifier)(predicateQuantifier)) {
@ -589,7 +614,7 @@ async function run() {
const files = await getChangedFiles(token, base, ref, initialFetchDepth);
core.info(`Detected ${files.length} changed files`);
const results = filter.match(files);
exportResults(results, listFiles);
exportResults(results, listFilesFormat, statFormat);
}
catch (error) {
core.setFailed(getErrorMessage(error));
@ -718,12 +743,16 @@ async function getChangedFilesFromApi(token, pullRequest) {
if (row.status === file_1.ChangeStatus.Renamed) {
files.push({
filename: row.filename,
status: file_1.ChangeStatus.Added
status: file_1.ChangeStatus.Added,
additions: row.additions,
deletions: row.deletions
});
files.push({
// 'previous_filename' for some unknown reason isn't in the type definition or documentation
filename: row.previous_filename,
status: file_1.ChangeStatus.Deleted
status: file_1.ChangeStatus.Deleted,
additions: row.additions,
deletions: row.deletions
});
}
else {
@ -731,7 +760,9 @@ async function getChangedFilesFromApi(token, pullRequest) {
const status = row.status === 'removed' ? file_1.ChangeStatus.Deleted : row.status;
files.push({
filename: row.filename,
status
status,
additions: row.additions,
deletions: row.deletions
});
}
}
@ -742,12 +773,13 @@ async function getChangedFilesFromApi(token, pullRequest) {
core.endGroup();
}
}
function exportResults(results, format) {
function exportResults(results, filesFormat, statFormat) {
core.info('Results:');
const changes = [];
const changeStats = {};
for (const [key, files] of Object.entries(results)) {
const value = files.length > 0;
core.startGroup(`Filter ${key} = ${value}`);
const hasMatchingFiles = files.length > 0;
core.startGroup(`Filter ${key} = ${hasMatchingFiles}`);
if (files.length > 0) {
changes.push(key);
core.info('Matching files:');
@ -758,12 +790,22 @@ function exportResults(results, format) {
else {
core.info('Matching files: none');
}
core.setOutput(key, value);
core.setOutput(key, hasMatchingFiles);
core.setOutput(`${key}_count`, files.length);
if (format !== 'none') {
const filesValue = serializeExport(files, format);
if (filesFormat !== 'none') {
const filesValue = serializeExportChangedFiles(files, filesFormat);
core.setOutput(`${key}_files`, filesValue);
}
const additionCount = files.reduce((sum, f) => sum + f.additions, 0);
const deletionCount = files.reduce((sum, f) => sum + f.deletions, 0);
core.setOutput(`${key}_addition_count`, additionCount);
core.setOutput(`${key}_deletion_count`, deletionCount);
core.setOutput(`${key}_change_count`, additionCount + deletionCount);
changeStats[key] = {
additionCount,
deletionCount,
fileCount: files.length
};
core.endGroup();
}
if (results['changes'] === undefined) {
@ -774,8 +816,12 @@ function exportResults(results, format) {
else {
core.info('Cannot set changes output variable - name already used by filter output');
}
if (statFormat !== 'none') {
const statValue = serializeExportStat(changeStats, statFormat);
core.setOutput(`stat`, statValue);
}
}
function serializeExport(files, format) {
function serializeExportChangedFiles(files, format) {
const fileNames = files.map(file => file.filename);
switch (format) {
case 'csv':
@ -790,7 +836,20 @@ function serializeExport(files, format) {
return '';
}
}
function isExportFormat(value) {
function serializeExportStat(stat, format) {
switch (format) {
case 'csv':
return Object.keys(stat)
.sort()
.map(k => [(0, csv_escape_1.csvEscape)(k), stat[k].additionCount, stat[k].deletionCount, stat[k].fileCount].join(','))
.join('\n');
case 'json':
return JSON.stringify(stat);
default:
return '';
}
}
function isFilesExportFormat(value) {
return ['none', 'csv', 'shell', 'json', 'escape'].includes(value);
}
function getErrorMessage(error) {
@ -798,6 +857,9 @@ function getErrorMessage(error) {
return error.message;
return String(error);
}
function isStatExportFormat(value) {
return ['none', 'csv', 'json'].includes(value);
}
run();
@ -26291,6 +26353,9 @@ function httpRedirectFetch (fetchParams, response) {
// https://fetch.spec.whatwg.org/#cors-non-wildcard-request-header-name
request.headersList.delete('authorization')
// https://fetch.spec.whatwg.org/#authentication-entries
request.headersList.delete('proxy-authorization', true)
// "Cookie" and "Host" are forbidden request-headers, which undici doesn't implement.
request.headersList.delete('cookie')
request.headersList.delete('host')

6
package-lock.json generated
View File

@ -6813,9 +6813,9 @@
}
},
"node_modules/undici": {
"version": "5.28.2",
"resolved": "https://registry.npmjs.org/undici/-/undici-5.28.2.tgz",
"integrity": "sha512-wh1pHJHnUeQV5Xa8/kyQhO7WFa8M34l026L5P/+2TYiakvGy5Rdc8jWZVyG7ieht/0WgJLEd3kcU5gKx+6GC8w==",
"version": "5.28.3",
"resolved": "https://registry.npmjs.org/undici/-/undici-5.28.3.tgz",
"integrity": "sha512-3ItfzbrhDlINjaP0duwnNsKpDQk3acHI3gVJ1z4fmwMK31k5G9OVIAMLSIaP6w4FaGkaAkN6zaQO9LUvZ1t7VA==",
"dependencies": {
"@fastify/busboy": "^2.0.0"
},

View File

@ -1,8 +1,16 @@
export interface File {
export interface FileStatus {
filename: string
status: ChangeStatus
}
export interface FileNumstat {
filename: string
additions: number
deletions: number
}
export type File = FileStatus & FileNumstat
export enum ChangeStatus {
Added = 'added',
Copied = 'copied',

View File

@ -1,6 +1,6 @@
import * as jsyaml from 'js-yaml'
import picomatch from 'picomatch'
import {File, ChangeStatus} from './file'
import {File, ChangeStatus, FileStatus} from './file'
// Type definition of object we expect to load from YAML
interface FilterYaml {
@ -100,10 +100,19 @@ export class Filter {
for (const [key, patterns] of Object.entries(this.rules)) {
result[key] = files.filter(file => this.isMatch(file, patterns))
}
if (!this.rules.hasOwnProperty('other')) {
const matchingFilenamesList = Object.values(result).flatMap(filteredFiles =>
filteredFiles.map(file => file.filename)
)
const matchingFilenamesSet = new Set(matchingFilenamesList)
result.other = files.filter(file => !matchingFilenamesSet.has(file.filename))
}
return result
}
private isMatch(file: File, patterns: FilterRuleItem[]): boolean {
private isMatch(file: FileStatus, patterns: FilterRuleItem[]): boolean {
const aPredicate = (rule: Readonly<FilterRuleItem>): boolean => {
return (rule.status === undefined || rule.status.includes(file.status)) && rule.isMatch(file.filename)
}

View File

@ -1,21 +1,27 @@
import {getExecOutput} from '@actions/exec'
import * as core from '@actions/core'
import {File, ChangeStatus} from './file'
import {File, ChangeStatus, FileNumstat, FileStatus} from './file'
export const NULL_SHA = '0000000000000000000000000000000000000000'
export const HEAD = 'HEAD'
export async function getChangesInLastCommit(): Promise<File[]> {
core.startGroup(`Change detection in last commit`)
let output = ''
try {
output = (await getExecOutput('git', ['log', '--format=', '--no-renames', '--name-status', '-z', '-n', '1'])).stdout
} finally {
fixStdOutNullTermination()
core.endGroup()
}
return parseGitDiffOutput(output)
return core.group(`Change detection in last commit`, async () => {
try {
// Calling git log on the last commit works when only the last commit may be checked out. Calling git diff HEAD^..HEAD needs two commits.
const statusOutput = (
await getExecOutput('git', ['log', '--format=', '--no-renames', '--name-status', '-z', '-n', '1'])
).stdout
const numstatOutput = (
await getExecOutput('git', ['log', '--format=', '--no-renames', '--numstat', '-z', '-n', '1'])
).stdout
const statusFiles = parseGitDiffNameStatusOutput(statusOutput)
const numstatFiles = parseGitDiffNumstatOutput(numstatOutput)
return mergeStatusNumstat(statusFiles, numstatFiles)
} finally {
fixStdOutNullTermination()
}
})
}
export async function getChanges(base: string, head: string): Promise<File[]> {
@ -23,32 +29,13 @@ export async function getChanges(base: string, head: string): Promise<File[]> {
const headRef = await ensureRefAvailable(head)
// Get differences between ref and HEAD
core.startGroup(`Change detection ${base}..${head}`)
let output = ''
try {
// Two dots '..' change detection - directly compares two versions
output = (await getExecOutput('git', ['diff', '--no-renames', '--name-status', '-z', `${baseRef}..${headRef}`]))
.stdout
} finally {
fixStdOutNullTermination()
core.endGroup()
}
return parseGitDiffOutput(output)
// Two dots '..' change detection - directly compares two versions
return core.group(`Change detection ${base}..${head}`, () => getGitDiffStatusNumstat(`${baseRef}..${headRef}`))
}
export async function getChangesOnHead(): Promise<File[]> {
// Get current changes - both staged and unstaged
core.startGroup(`Change detection on HEAD`)
let output = ''
try {
output = (await getExecOutput('git', ['diff', '--no-renames', '--name-status', '-z', 'HEAD'])).stdout
} finally {
fixStdOutNullTermination()
core.endGroup()
}
return parseGitDiffOutput(output)
return core.group(`Change detection on HEAD`, () => getGitDiffStatusNumstat(`HEAD`))
}
export async function getChangesSinceMergeBase(base: string, head: string, initialFetchDepth: number): Promise<File[]> {
@ -120,21 +107,32 @@ export async function getChangesSinceMergeBase(base: string, head: string, initi
}
// Get changes introduced on ref compared to base
core.startGroup(`Change detection ${diffArg}`)
return getGitDiffStatusNumstat(diffArg)
}
async function gitDiffNameStatus(diffArg: string): Promise<string> {
let output = ''
try {
output = (await getExecOutput('git', ['diff', '--no-renames', '--name-status', '-z', diffArg])).stdout
} finally {
fixStdOutNullTermination()
core.endGroup()
}
return parseGitDiffOutput(output)
return output
}
export function parseGitDiffOutput(output: string): File[] {
async function gitDiffNumstat(diffArg: string): Promise<string> {
let output = ''
try {
output = (await getExecOutput('git', ['diff', '--no-renames', '--numstat', '-z', diffArg])).stdout
} finally {
fixStdOutNullTermination()
}
return output
}
export function parseGitDiffNameStatusOutput(output: string): FileStatus[] {
const tokens = output.split('\u0000').filter(s => s.length > 0)
const files: File[] = []
const files: FileStatus[] = []
for (let i = 0; i + 1 < tokens.length; i += 2) {
files.push({
status: statusMap[tokens[i]],
@ -144,23 +142,45 @@ export function parseGitDiffOutput(output: string): File[] {
return files
}
export async function listAllFilesAsAdded(): Promise<File[]> {
core.startGroup('Listing all files tracked by git')
let output = ''
try {
output = (await getExecOutput('git', ['ls-files', '-z'])).stdout
} finally {
fixStdOutNullTermination()
core.endGroup()
}
function mergeStatusNumstat(statusEntries: FileStatus[], numstatEntries: FileNumstat[]): File[] {
const statusMap: {[key: string]: FileStatus} = {}
statusEntries.forEach(f => (statusMap[f.filename] = f))
return output
.split('\u0000')
.filter(s => s.length > 0)
.map(path => ({
status: ChangeStatus.Added,
filename: path
}))
return numstatEntries.map(f => {
const status = statusMap[f.filename]
if (!status) {
throw new Error(`Cannot find the status entry for file: ${f.filename}`)
}
return {...f, status: status.status}
})
}
export async function getGitDiffStatusNumstat(diffArg: string) {
const statusFiles = await gitDiffNameStatus(diffArg).then(parseGitDiffNameStatusOutput)
const numstatFiles = await gitDiffNumstat(diffArg).then(parseGitDiffNumstatOutput)
return mergeStatusNumstat(statusFiles, numstatFiles)
}
export function parseGitDiffNumstatOutput(output: string): FileNumstat[] {
const rows = output.split('\u0000').filter(s => s.length > 0)
return rows.map(row => {
const tokens = row.split('\t')
// For the binary files set the numbers to zero. This matches the response of Github API.
const additions = tokens[0] == '-' ? 0 : Number.parseInt(tokens[0])
const deletions = tokens[1] == '-' ? 0 : Number.parseInt(tokens[1])
return {
filename: tokens[2],
additions,
deletions
}
})
}
export async function listAllFilesAsAdded(): Promise<File[]> {
return core.group(`Listing all files tracked by git`, async () => {
const emptyTreeHash = (await getExecOutput('git', ['hash-object', '-t', 'tree', '/dev/null'])).stdout
return getGitDiffStatusNumstat(emptyTreeHash)
})
}
export async function getCurrentRef(): Promise<string> {

View File

@ -17,7 +17,8 @@ import * as git from './git'
import {backslashEscape, shellEscape} from './list-format/shell-escape'
import {csvEscape} from './list-format/csv-escape'
type ExportFormat = 'none' | 'csv' | 'json' | 'shell' | 'escape'
type FilesExportFormat = 'none' | 'csv' | 'json' | 'shell' | 'escape'
type StatExportFormat = 'none' | 'csv' | 'json'
async function run(): Promise<void> {
try {
@ -31,12 +32,18 @@ async function run(): Promise<void> {
const base = core.getInput('base', {required: false})
const filtersInput = core.getInput('filters', {required: true})
const filtersYaml = isPathInput(filtersInput) ? getConfigFileContent(filtersInput) : filtersInput
const listFiles = core.getInput('list-files', {required: false}).toLowerCase() || 'none'
const listFilesFormat = core.getInput('list-files', {required: false}).toLowerCase() || 'none'
const statFormat = core.getInput('stat', {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}'`)
if (!isFilesExportFormat(listFilesFormat)) {
core.setFailed(`Input parameter 'list-files' is set to invalid value '${listFilesFormat}'`)
return
}
if (!isStatExportFormat(statFormat)) {
core.setFailed(`Input parameter 'stat' is set to invalid value '${statFormat}'`)
return
}
@ -52,7 +59,7 @@ async function run(): Promise<void> {
const files = await getChangedFiles(token, base, ref, initialFetchDepth)
core.info(`Detected ${files.length} changed files`)
const results = filter.match(files)
exportResults(results, listFiles)
exportResults(results, listFilesFormat, statFormat)
} catch (error) {
core.setFailed(getErrorMessage(error))
}
@ -204,19 +211,25 @@ async function getChangedFilesFromApi(token: string, pullRequest: PullRequestEve
if (row.status === ChangeStatus.Renamed) {
files.push({
filename: row.filename,
status: ChangeStatus.Added
status: ChangeStatus.Added,
additions: row.additions,
deletions: row.deletions
})
files.push({
// 'previous_filename' for some unknown reason isn't in the type definition or documentation
filename: (<any>row).previous_filename as string,
status: ChangeStatus.Deleted
status: ChangeStatus.Deleted,
additions: row.additions,
deletions: row.deletions
})
} else {
// Github status and git status variants are same except for deleted files
const status = row.status === 'removed' ? ChangeStatus.Deleted : (row.status as ChangeStatus)
files.push({
filename: row.filename,
status
status,
additions: row.additions,
deletions: row.deletions
})
}
}
@ -228,12 +241,20 @@ async function getChangedFilesFromApi(token: string, pullRequest: PullRequestEve
}
}
function exportResults(results: FilterResults, format: ExportFormat): void {
interface Stat {
additionCount: number
deletionCount: number
fileCount: number
}
function exportResults(results: FilterResults, filesFormat: FilesExportFormat, statFormat: StatExportFormat): void {
core.info('Results:')
const changes = []
const changes: string[] = []
const changeStats: {[key: string]: Stat} = {}
for (const [key, files] of Object.entries(results)) {
const value = files.length > 0
core.startGroup(`Filter ${key} = ${value}`)
const hasMatchingFiles = files.length > 0
core.startGroup(`Filter ${key} = ${hasMatchingFiles}`)
if (files.length > 0) {
changes.push(key)
core.info('Matching files:')
@ -244,12 +265,24 @@ function exportResults(results: FilterResults, format: ExportFormat): void {
core.info('Matching files: none')
}
core.setOutput(key, value)
core.setOutput(key, hasMatchingFiles)
core.setOutput(`${key}_count`, files.length)
if (format !== 'none') {
const filesValue = serializeExport(files, format)
if (filesFormat !== 'none') {
const filesValue = serializeExportChangedFiles(files, filesFormat)
core.setOutput(`${key}_files`, filesValue)
}
const additionCount: number = files.reduce((sum, f) => sum + f.additions, 0)
const deletionCount: number = files.reduce((sum, f) => sum + f.deletions, 0)
core.setOutput(`${key}_addition_count`, additionCount)
core.setOutput(`${key}_deletion_count`, deletionCount)
core.setOutput(`${key}_change_count`, additionCount + deletionCount)
changeStats[key] = {
additionCount,
deletionCount,
fileCount: files.length
}
core.endGroup()
}
@ -260,9 +293,14 @@ function exportResults(results: FilterResults, format: ExportFormat): void {
} else {
core.info('Cannot set changes output variable - name already used by filter output')
}
if (statFormat !== 'none') {
const statValue = serializeExportStat(changeStats, statFormat)
core.setOutput(`stat`, statValue)
}
}
function serializeExport(files: File[], format: ExportFormat): string {
function serializeExportChangedFiles(files: File[], format: FilesExportFormat): string {
const fileNames = files.map(file => file.filename)
switch (format) {
case 'csv':
@ -278,7 +316,21 @@ function serializeExport(files: File[], format: ExportFormat): string {
}
}
function isExportFormat(value: string): value is ExportFormat {
function serializeExportStat(stat: {[key: string]: Stat}, format: StatExportFormat): string {
switch (format) {
case 'csv':
return Object.keys(stat)
.sort()
.map(k => [csvEscape(k), stat[k].additionCount, stat[k].deletionCount, stat[k].fileCount].join(','))
.join('\n')
case 'json':
return JSON.stringify(stat)
default:
return ''
}
}
function isFilesExportFormat(value: string): value is FilesExportFormat {
return ['none', 'csv', 'shell', 'json', 'escape'].includes(value)
}
@ -287,4 +339,8 @@ function getErrorMessage(error: unknown): string {
return String(error)
}
function isStatExportFormat(value: string): value is StatExportFormat {
return ['none', 'csv', 'json'].includes(value)
}
run()