diff --git a/README.md b/README.md
index 106b471a..e84497e3 100644
--- a/README.md
+++ b/README.md
@@ -70,6 +70,7 @@ For more scenarios see [examples](#examples) section.
## What's New
+- 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
@@ -160,6 +161,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
@@ -504,6 +506,33 @@ jobs:
+
+ Passing number of added lines from a filter to another action
+
+```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 }}
+```
+
+
+
## See also
- [test-reporter](https://github.com/dorny/test-reporter) - Displays test results from popular testing frameworks directly in GitHub
diff --git a/__tests__/filter.test.ts b/__tests__/filter.test.ts
index be2a1487..b1f4bbb1 100644
--- a/__tests__/filter.test.ts
+++ b/__tests__/filter.test.ts
@@ -150,7 +150,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)
})
@@ -161,7 +161,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)
})
@@ -183,6 +183,6 @@ describe('matching specific change status', () => {
function modified(paths: string[]): File[] {
return paths.map(filename => {
- return {filename, status: ChangeStatus.Modified}
+ return {filename, status: ChangeStatus.Modified, additions: 1, deletions: 1}
})
}
diff --git a/__tests__/git.test.ts b/__tests__/git.test.ts
index 9645221e..d543dce6 100644
--- a/__tests__/git.test.ts
+++ b/__tests__/git.test.ts
@@ -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,19 @@ 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)', () => {
diff --git a/action.yml b/action.yml
index dc515281..65128ddc 100644
--- a/action.yml
+++ b/action.yml
@@ -36,6 +36,16 @@ inputs:
Backslash escapes every potentially unsafe character.
required: true
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.
diff --git a/dist/index.js b/dist/index.js
index 7f134f10..600b2085 100644
--- a/dist/index.js
+++ b/dist/index.js
@@ -3811,54 +3811,39 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
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 = __importDefault(__webpack_require__(807));
const core = __importStar(__webpack_require__(470));
const file_1 = __webpack_require__(258);
exports.NULL_SHA = '0000000000000000000000000000000000000000';
exports.HEAD = 'HEAD';
async function getChangesInLastCommit() {
- core.startGroup(`Change detection in last commit`);
- let output = '';
- try {
- output = (await exec_1.default('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 exec_1.default('git', ['log', '--format=', '--no-renames', '--name-status', '-z', '-n', '1'])).stdout;
+ const numstatOutput = (await exec_1.default('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 exec_1.default('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 exec_1.default('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) {
@@ -3923,19 +3908,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 exec_1.default('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 exec_1.default('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) {
@@ -3946,24 +3942,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 exec_1.default('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 exec_1.default('git', ['hash-object', '-t', 'tree', '/dev/null'])).stdout;
+ return getGitDiffStatusNumstat(emptyTreeHash);
+ });
}
exports.listAllFilesAsAdded = listAllFilesAsAdded;
async function getCurrentRef() {
@@ -4716,17 +4732,22 @@ 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;
- 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;
}
const filter = new filter_1.Filter(filtersYaml);
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(error.message);
@@ -4857,12 +4878,16 @@ async function getChangedFilesFromApi(token, prNumber) {
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 {
@@ -4870,7 +4895,9 @@ async function getChangedFilesFromApi(token, prNumber) {
const status = row.status === 'removed' ? file_1.ChangeStatus.Deleted : row.status;
files.push({
filename: row.filename,
- status
+ status,
+ additions: row.additions,
+ deletions: row.deletions,
});
}
}
@@ -4881,12 +4908,13 @@ async function getChangedFilesFromApi(token, prNumber) {
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:');
@@ -4897,12 +4925,20 @@ 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) {
@@ -4913,8 +4949,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':
@@ -4929,7 +4969,21 @@ function serializeExport(files, format) {
return '';
}
}
-function isExportFormat(value) {
+function serializeExportStat(stat, format) {
+ switch (format) {
+ case 'csv':
+ return Object.keys(stat).sort().map(k => [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 isStatExportFormat(value) {
return ['none', 'csv', 'shell', 'json', 'escape'].includes(value);
}
run();
@@ -5171,7 +5225,7 @@ module.exports = require("https");
/***/ 215:
/***/ (function(module) {
-module.exports = {"_args":[["@octokit/rest@16.43.1","C:\\Users\\Michal\\Workspace\\dorny\\pr-changed-files-filter"]],"_from":"@octokit/rest@16.43.1","_id":"@octokit/rest@16.43.1","_inBundle":false,"_integrity":"sha512-gfFKwRT/wFxq5qlNjnW2dh+qh74XgTQ2B179UX5K1HYCluioWj8Ndbgqw2PVqa1NnVJkGHp2ovMpVn/DImlmkw==","_location":"/@octokit/rest","_phantomChildren":{"@types/node":"14.0.5","deprecation":"2.3.1","once":"1.4.0","os-name":"3.1.0"},"_requested":{"type":"version","registry":true,"raw":"@octokit/rest@16.43.1","name":"@octokit/rest","escapedName":"@octokit%2frest","scope":"@octokit","rawSpec":"16.43.1","saveSpec":null,"fetchSpec":"16.43.1"},"_requiredBy":["/@actions/github"],"_resolved":"https://registry.npmjs.org/@octokit/rest/-/rest-16.43.1.tgz","_spec":"16.43.1","_where":"C:\\Users\\Michal\\Workspace\\dorny\\pr-changed-files-filter","author":{"name":"Gregor Martynus","url":"https://github.com/gr2m"},"bugs":{"url":"https://github.com/octokit/rest.js/issues"},"bundlesize":[{"path":"./dist/octokit-rest.min.js.gz","maxSize":"33 kB"}],"contributors":[{"name":"Mike de Boer","email":"info@mikedeboer.nl"},{"name":"Fabian Jakobs","email":"fabian@c9.io"},{"name":"Joe Gallo","email":"joe@brassafrax.com"},{"name":"Gregor Martynus","url":"https://github.com/gr2m"}],"dependencies":{"@octokit/auth-token":"^2.4.0","@octokit/plugin-paginate-rest":"^1.1.1","@octokit/plugin-request-log":"^1.0.0","@octokit/plugin-rest-endpoint-methods":"2.4.0","@octokit/request":"^5.2.0","@octokit/request-error":"^1.0.2","atob-lite":"^2.0.0","before-after-hook":"^2.0.0","btoa-lite":"^1.0.0","deprecation":"^2.0.0","lodash.get":"^4.4.2","lodash.set":"^4.3.2","lodash.uniq":"^4.5.0","octokit-pagination-methods":"^1.1.0","once":"^1.4.0","universal-user-agent":"^4.0.0"},"description":"GitHub REST API client for Node.js","devDependencies":{"@gimenete/type-writer":"^0.1.3","@octokit/auth":"^1.1.1","@octokit/fixtures-server":"^5.0.6","@octokit/graphql":"^4.2.0","@types/node":"^13.1.0","bundlesize":"^0.18.0","chai":"^4.1.2","compression-webpack-plugin":"^3.1.0","cypress":"^3.0.0","glob":"^7.1.2","http-proxy-agent":"^4.0.0","lodash.camelcase":"^4.3.0","lodash.merge":"^4.6.1","lodash.upperfirst":"^4.3.1","lolex":"^5.1.2","mkdirp":"^1.0.0","mocha":"^7.0.1","mustache":"^4.0.0","nock":"^11.3.3","npm-run-all":"^4.1.2","nyc":"^15.0.0","prettier":"^1.14.2","proxy":"^1.0.0","semantic-release":"^17.0.0","sinon":"^8.0.0","sinon-chai":"^3.0.0","sort-keys":"^4.0.0","string-to-arraybuffer":"^1.0.0","string-to-jsdoc-comment":"^1.0.0","typescript":"^3.3.1","webpack":"^4.0.0","webpack-bundle-analyzer":"^3.0.0","webpack-cli":"^3.0.0"},"files":["index.js","index.d.ts","lib","plugins"],"homepage":"https://github.com/octokit/rest.js#readme","keywords":["octokit","github","rest","api-client"],"license":"MIT","name":"@octokit/rest","nyc":{"ignore":["test"]},"publishConfig":{"access":"public"},"release":{"publish":["@semantic-release/npm",{"path":"@semantic-release/github","assets":["dist/*","!dist/*.map.gz"]}]},"repository":{"type":"git","url":"git+https://github.com/octokit/rest.js.git"},"scripts":{"build":"npm-run-all build:*","build:browser":"npm-run-all build:browser:*","build:browser:development":"webpack --mode development --entry . --output-library=Octokit --output=./dist/octokit-rest.js --profile --json > dist/bundle-stats.json","build:browser:production":"webpack --mode production --entry . --plugin=compression-webpack-plugin --output-library=Octokit --output-path=./dist --output-filename=octokit-rest.min.js --devtool source-map","build:ts":"npm run -s update-endpoints:typescript","coverage":"nyc report --reporter=html && open coverage/index.html","generate-bundle-report":"webpack-bundle-analyzer dist/bundle-stats.json --mode=static --no-open --report dist/bundle-report.html","lint":"prettier --check '{lib,plugins,scripts,test}/**/*.{js,json,ts}' 'docs/*.{js,json}' 'docs/src/**/*' index.js README.md package.json","lint:fix":"prettier --write '{lib,plugins,scripts,test}/**/*.{js,json,ts}' 'docs/*.{js,json}' 'docs/src/**/*' index.js README.md package.json","postvalidate:ts":"tsc --noEmit --target es6 test/typescript-validate.ts","prebuild:browser":"mkdirp dist/","pretest":"npm run -s lint","prevalidate:ts":"npm run -s build:ts","start-fixtures-server":"octokit-fixtures-server","test":"nyc mocha test/mocha-node-setup.js \"test/*/**/*-test.js\"","test:browser":"cypress run --browser chrome","update-endpoints":"npm-run-all update-endpoints:*","update-endpoints:fetch-json":"node scripts/update-endpoints/fetch-json","update-endpoints:typescript":"node scripts/update-endpoints/typescript","validate:ts":"tsc --target es6 --noImplicitAny index.d.ts"},"types":"index.d.ts","version":"16.43.1"};
+module.exports = {"name":"@octokit/rest","version":"16.43.1","publishConfig":{"access":"public"},"description":"GitHub REST API client for Node.js","keywords":["octokit","github","rest","api-client"],"author":"Gregor Martynus (https://github.com/gr2m)","contributors":[{"name":"Mike de Boer","email":"info@mikedeboer.nl"},{"name":"Fabian Jakobs","email":"fabian@c9.io"},{"name":"Joe Gallo","email":"joe@brassafrax.com"},{"name":"Gregor Martynus","url":"https://github.com/gr2m"}],"repository":"https://github.com/octokit/rest.js","dependencies":{"@octokit/auth-token":"^2.4.0","@octokit/plugin-paginate-rest":"^1.1.1","@octokit/plugin-request-log":"^1.0.0","@octokit/plugin-rest-endpoint-methods":"2.4.0","@octokit/request":"^5.2.0","@octokit/request-error":"^1.0.2","atob-lite":"^2.0.0","before-after-hook":"^2.0.0","btoa-lite":"^1.0.0","deprecation":"^2.0.0","lodash.get":"^4.4.2","lodash.set":"^4.3.2","lodash.uniq":"^4.5.0","octokit-pagination-methods":"^1.1.0","once":"^1.4.0","universal-user-agent":"^4.0.0"},"devDependencies":{"@gimenete/type-writer":"^0.1.3","@octokit/auth":"^1.1.1","@octokit/fixtures-server":"^5.0.6","@octokit/graphql":"^4.2.0","@types/node":"^13.1.0","bundlesize":"^0.18.0","chai":"^4.1.2","compression-webpack-plugin":"^3.1.0","cypress":"^3.0.0","glob":"^7.1.2","http-proxy-agent":"^4.0.0","lodash.camelcase":"^4.3.0","lodash.merge":"^4.6.1","lodash.upperfirst":"^4.3.1","lolex":"^5.1.2","mkdirp":"^1.0.0","mocha":"^7.0.1","mustache":"^4.0.0","nock":"^11.3.3","npm-run-all":"^4.1.2","nyc":"^15.0.0","prettier":"^1.14.2","proxy":"^1.0.0","semantic-release":"^17.0.0","sinon":"^8.0.0","sinon-chai":"^3.0.0","sort-keys":"^4.0.0","string-to-arraybuffer":"^1.0.0","string-to-jsdoc-comment":"^1.0.0","typescript":"^3.3.1","webpack":"^4.0.0","webpack-bundle-analyzer":"^3.0.0","webpack-cli":"^3.0.0"},"types":"index.d.ts","scripts":{"coverage":"nyc report --reporter=html && open coverage/index.html","lint":"prettier --check '{lib,plugins,scripts,test}/**/*.{js,json,ts}' 'docs/*.{js,json}' 'docs/src/**/*' index.js README.md package.json","lint:fix":"prettier --write '{lib,plugins,scripts,test}/**/*.{js,json,ts}' 'docs/*.{js,json}' 'docs/src/**/*' index.js README.md package.json","pretest":"npm run -s lint","test":"nyc mocha test/mocha-node-setup.js \"test/*/**/*-test.js\"","test:browser":"cypress run --browser chrome","build":"npm-run-all build:*","build:ts":"npm run -s update-endpoints:typescript","prebuild:browser":"mkdirp dist/","build:browser":"npm-run-all build:browser:*","build:browser:development":"webpack --mode development --entry . --output-library=Octokit --output=./dist/octokit-rest.js --profile --json > dist/bundle-stats.json","build:browser:production":"webpack --mode production --entry . --plugin=compression-webpack-plugin --output-library=Octokit --output-path=./dist --output-filename=octokit-rest.min.js --devtool source-map","generate-bundle-report":"webpack-bundle-analyzer dist/bundle-stats.json --mode=static --no-open --report dist/bundle-report.html","update-endpoints":"npm-run-all update-endpoints:*","update-endpoints:fetch-json":"node scripts/update-endpoints/fetch-json","update-endpoints:typescript":"node scripts/update-endpoints/typescript","prevalidate:ts":"npm run -s build:ts","validate:ts":"tsc --target es6 --noImplicitAny index.d.ts","postvalidate:ts":"tsc --noEmit --target es6 test/typescript-validate.ts","start-fixtures-server":"octokit-fixtures-server"},"license":"MIT","files":["index.js","index.d.ts","lib","plugins"],"nyc":{"ignore":["test"]},"release":{"publish":["@semantic-release/npm",{"path":"@semantic-release/github","assets":["dist/*","!dist/*.map.gz"]}]},"bundlesize":[{"path":"./dist/octokit-rest.min.js.gz","maxSize":"33 kB"}]};
/***/ }),
@@ -5351,6 +5405,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) {
diff --git a/src/file.ts b/src/file.ts
index d8125a7a..b5d3ea27 100644
--- a/src/file.ts
+++ b/src/file.ts
@@ -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',
diff --git a/src/filter.ts b/src/filter.ts
index cfc74a3c..125fb0b4 100644
--- a/src/filter.ts
+++ b/src/filter.ts
@@ -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 {
@@ -58,10 +58,17 @@ 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 {
return patterns.some(
rule => (rule.status === undefined || rule.status.includes(file.status)) && rule.isMatch(file.filename)
)
diff --git a/src/git.ts b/src/git.ts
index 3d6d3be1..ba3acea5 100644
--- a/src/git.ts
+++ b/src/git.ts
@@ -1,21 +1,23 @@
import exec from './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 {
- core.startGroup(`Change detection in last commit`)
- let output = ''
- try {
- output = (await exec('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 exec('git', ['log', '--format=', '--no-renames', '--name-status', '-z', '-n', '1'])).stdout
+ const numstatOutput = (await exec('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 {
@@ -23,31 +25,17 @@ export async function getChanges(base: string, head: string): Promise {
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 exec('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 {
// Get current changes - both staged and unstaged
- core.startGroup(`Change detection on HEAD`)
- let output = ''
- try {
- output = (await exec('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 {
@@ -119,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 {
let output = ''
try {
output = (await exec('git', ['diff', '--no-renames', '--name-status', '-z', diffArg])).stdout
} finally {
fixStdOutNullTermination()
- core.endGroup()
}
+ return output
+}
- return parseGitDiffOutput(output)
+async function gitDiffNumstat(diffArg: string): Promise {
+ let output = ''
+ try {
+ output = (await exec('git', ['diff', '--no-renames', '--numstat', '-z', diffArg])).stdout
+ } finally {
+ fixStdOutNullTermination()
+ }
+ return output
}
-export function parseGitDiffOutput(output: string): File[] {
+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]],
@@ -143,23 +142,46 @@ export function parseGitDiffOutput(output: string): File[] {
return files
}
-export async function listAllFilesAsAdded(): Promise {
- core.startGroup('Listing all files tracked by git')
- let output = ''
- try {
- output = (await exec('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 {
+ return core.group(`Listing all files tracked by git`, async () => {
+ const emptyTreeHash = (await exec('git', ['hash-object', '-t', 'tree', '/dev/null'])).stdout
+ return getGitDiffStatusNumstat(emptyTreeHash)
+ })
}
export async function getCurrentRef(): Promise {
diff --git a/src/main.ts b/src/main.ts
index d2cb6784..ffc49023 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -9,7 +9,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 {
try {
@@ -23,11 +24,17 @@ async function run(): Promise {
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
- 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
}
@@ -35,7 +42,7 @@ async function run(): Promise {
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(error.message)
}
@@ -194,19 +201,25 @@ async function getChangedFilesFromApi(
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: (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,
})
}
}
@@ -218,12 +231,20 @@ async function getChangedFilesFromApi(
}
}
-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:')
@@ -234,12 +255,22 @@ 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()
}
@@ -250,9 +281,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':
@@ -268,8 +304,26 @@ 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)
}
+function isStatExportFormat(value: string): value is StatExportFormat {
+ return ['none', 'csv', 'json'].includes(value)
+}
+
run()