Change detection using git "three dot" diff (#35)

* Rework change detection via `git diff`

Previous implementation performed simple diff between two versions. New implementation fetches on demand more commits to have the merge base between two branches. Now it will detect only changes introduced by branch that was pushed, instead of mixing with changes introduced meanwhile on the base branch.
This commit is contained in:
Michal Dorner 2020-09-01 22:47:38 +02:00 committed by GitHub
parent 3f845744aa
commit 81c90ccae8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 383 additions and 256 deletions

View File

@ -75,7 +75,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- run: touch add.txt && rm README.md && echo "TEST" > LICENSE && git add -A
- name: configure GIT user
run: git config user.email "john@nowhere.local" && git config user.name "John Doe"
- name: modify working tree
run: touch add.txt && rm README.md && echo "TEST" > LICENSE
- name: commit changes
run: git add -A && git commit -a -m 'testing this action'
- uses: ./
id: filter
with:

View File

@ -1,18 +1,11 @@
import * as git from '../src/git'
import {ExecOptions} from '@actions/exec'
import {ChangeStatus} from '../src/file'
describe('parsing of the git diff-index command', () => {
test('getChangedFiles returns files with correct change status', async () => {
const files = await git.getChangedFiles(git.FETCH_HEAD, (cmd, args, opts) => {
const stdout = opts?.listeners?.stdout
if (stdout) {
stdout(Buffer.from('A\u0000LICENSE\u0000'))
stdout(Buffer.from('M\u0000src/index.ts\u0000'))
stdout(Buffer.from('D\u0000src/main.ts\u0000'))
}
return Promise.resolve(0)
})
describe('parsing output of the git diff command', () => {
test('parseGitDiffOutput returns files with correct change status', async () => {
const files = git.parseGitDiffOutput(
'A\u0000LICENSE\u0000' + 'M\u0000src/index.ts\u0000' + 'D\u0000src/main.ts\u0000'
)
expect(files.length).toBe(3)
expect(files[0].filename).toBe('LICENSE')
expect(files[0].status).toBe(ChangeStatus.Added)

View File

@ -17,15 +17,23 @@ inputs:
required: false
filters:
description: 'Path to the configuration file or YAML string with filters definition'
required: false
required: true
list-files:
description: |
Enables listing of files matching the filter:
'none' - Disables listing of matching files (default).
'json' - Matching files paths are serialized as JSON array.
'shell' - Matching files paths are escaped and space-delimited. Output is usable as command line argument list in linux shell.
required: false
required: true
default: none
initial-fetch-depth:
description: |
How many commits are initially fetched from base branch.
If needed, each subsequent fetch doubles the previously requested number of commits
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'
runs:
using: 'node12'
main: 'dist/index.js'

394
dist/index.js vendored
View File

@ -3807,58 +3807,115 @@ var __importStar = (this && this.__importStar) || function (mod) {
__setModuleDefault(result, mod);
return result;
};
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.trimRefsHeads = exports.trimRefs = exports.isTagRef = exports.getChangedFiles = exports.fetchCommit = exports.FETCH_HEAD = exports.NULL_SHA = void 0;
exports.trimRefsHeads = exports.trimRefs = exports.isTagRef = exports.listAllFilesAsAdded = exports.parseGitDiffOutput = exports.getChangesSinceRef = exports.getChangesAgainstSha = exports.NULL_SHA = void 0;
const exec_1 = __webpack_require__(986);
const core = __importStar(__webpack_require__(470));
const file_1 = __webpack_require__(258);
exports.NULL_SHA = '0000000000000000000000000000000000000000';
exports.FETCH_HEAD = 'FETCH_HEAD';
function fetchCommit(ref) {
return __awaiter(this, void 0, void 0, function* () {
const exitCode = yield exec_1.exec('git', ['fetch', '--depth=1', '--no-tags', 'origin', ref]);
if (exitCode !== 0) {
throw new Error(`Fetching ${ref} failed`);
}
});
}
exports.fetchCommit = fetchCommit;
function getChangedFiles(ref, cmd = exec_1.exec) {
return __awaiter(this, void 0, void 0, function* () {
let output = '';
const exitCode = yield cmd('git', ['diff-index', '--name-status', '-z', ref], {
async function getChangesAgainstSha(sha) {
// Fetch single commit
core.startGroup(`Fetching ${sha} from origin`);
await exec_1.exec('git', ['fetch', '--depth=1', '--no-tags', 'origin', sha]);
core.endGroup();
// Get differences between sha and HEAD
core.startGroup(`Change detection ${sha}..HEAD`);
let output = '';
try {
// Two dots '..' change detection - directly compares two versions
await exec_1.exec('git', ['diff', '--no-renames', '--name-status', '-z', `${sha}..HEAD`], {
listeners: {
stdout: (data) => (output += data.toString())
}
});
if (exitCode !== 0) {
throw new Error(`Couldn't determine changed files`);
}
// Previous command uses NULL as delimiters and output is printed to stdout.
// We have to make sure next thing written to stdout will start on new line.
// Otherwise things like ::set-output wouldn't work.
core.info('');
const tokens = output.split('\u0000').filter(s => s.length > 0);
const files = [];
for (let i = 0; i + 1 < tokens.length; i += 2) {
files.push({
status: statusMap[tokens[i]],
filename: tokens[i + 1]
});
}
return files;
});
}
finally {
fixStdOutNullTermination();
core.endGroup();
}
return parseGitDiffOutput(output);
}
exports.getChangedFiles = getChangedFiles;
exports.getChangesAgainstSha = getChangesAgainstSha;
async function getChangesSinceRef(ref, initialFetchDepth) {
// Fetch and add base branch
core.startGroup(`Fetching ${ref} from origin until merge-base is found`);
await exec_1.exec('git', ['fetch', `--depth=${initialFetchDepth}`, '--no-tags', 'origin', `${ref}:${ref}`]);
async function hasMergeBase() {
return (await exec_1.exec('git', ['merge-base', ref, 'HEAD'], { ignoreReturnCode: true })) === 0;
}
async function countCommits() {
return (await getNumberOfCommits('HEAD')) + (await getNumberOfCommits(ref));
}
// Fetch more commits until merge-base is found
if (!(await hasMergeBase())) {
let deepen = initialFetchDepth;
let lastCommitsCount = await countCommits();
do {
await exec_1.exec('git', ['fetch', `--deepen=${deepen}`, '--no-tags', '--no-auto-gc', '-q']);
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();
}
lastCommitsCount = count;
deepen = Math.min(deepen * 2, Number.MAX_SAFE_INTEGER);
} while (!(await hasMergeBase()));
}
core.endGroup();
// Get changes introduced on HEAD compared to ref
core.startGroup(`Change detection ${ref}...HEAD`);
let output = '';
try {
// Three dots '...' change detection - finds merge-base and compares against it
await exec_1.exec('git', ['diff', '--no-renames', '--name-status', '-z', `${ref}...HEAD`], {
listeners: {
stdout: (data) => (output += data.toString())
}
});
}
finally {
fixStdOutNullTermination();
core.endGroup();
}
return parseGitDiffOutput(output);
}
exports.getChangesSinceRef = getChangesSinceRef;
function parseGitDiffOutput(output) {
const tokens = output.split('\u0000').filter(s => s.length > 0);
const files = [];
for (let i = 0; i + 1 < tokens.length; i += 2) {
files.push({
status: statusMap[tokens[i]],
filename: tokens[i + 1]
});
}
return files;
}
exports.parseGitDiffOutput = parseGitDiffOutput;
async function listAllFilesAsAdded() {
core.startGroup('Listing all files tracked by git');
let output = '';
try {
await exec_1.exec('git', ['ls-files', '-z'], {
listeners: {
stdout: (data) => (output += data.toString())
}
});
}
finally {
fixStdOutNullTermination();
core.endGroup();
}
return output
.split('\u0000')
.filter(s => s.length > 0)
.map(path => ({
status: file_1.ChangeStatus.Added,
filename: path
}));
}
exports.listAllFilesAsAdded = listAllFilesAsAdded;
function isTagRef(ref) {
return ref.startsWith('refs/tags/');
}
@ -3872,9 +3929,25 @@ function trimRefsHeads(ref) {
return trimStart(trimRef, 'heads/');
}
exports.trimRefsHeads = trimRefsHeads;
async function getNumberOfCommits(ref) {
let output = '';
await exec_1.exec('git', ['rev-list', `--count`, ref], {
listeners: {
stdout: (data) => (output += data.toString())
}
});
const count = parseInt(output);
return isNaN(count) ? 0 : count;
}
function trimStart(ref, start) {
return ref.startsWith(start) ? ref.substr(start.length) : ref;
}
function fixStdOutNullTermination() {
// Previous command uses NULL as delimiters and output is printed to stdout.
// We have to make sure next thing written to stdout will start on new line.
// Otherwise things like ::set-output wouldn't work.
core.info('');
}
const statusMap = {
A: file_1.ChangeStatus.Added,
C: file_1.ChangeStatus.Copied,
@ -4517,15 +4590,6 @@ var __importStar = (this && this.__importStar) || function (mod) {
__setModuleDefault(result, mod);
return result;
};
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
@ -4537,36 +4601,30 @@ const filter_1 = __webpack_require__(235);
const file_1 = __webpack_require__(258);
const git = __importStar(__webpack_require__(136));
const shell_escape_1 = __importDefault(__webpack_require__(751));
function run() {
return __awaiter(this, void 0, void 0, function* () {
try {
const workingDirectory = core.getInput('working-directory', { required: false });
if (workingDirectory) {
process.chdir(workingDirectory);
}
const token = core.getInput('token', { 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';
if (!isExportFormat(listFiles)) {
core.setFailed(`Input parameter 'list-files' is set to invalid value '${listFiles}'`);
return;
}
const filter = new filter_1.Filter(filtersYaml);
const files = yield getChangedFiles(token);
if (files === null) {
// Change detection was not possible
exportNoMatchingResults(filter);
}
else {
const results = filter.match(files);
exportResults(results, listFiles);
}
async function run() {
try {
const workingDirectory = core.getInput('working-directory', { required: false });
if (workingDirectory) {
process.chdir(workingDirectory);
}
catch (error) {
core.setFailed(error.message);
const token = core.getInput('token', { required: false });
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 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}'`);
return;
}
});
const filter = new filter_1.Filter(filtersYaml);
const files = await getChangedFiles(token, base, initialFetchDepth);
const results = filter.match(files);
exportResults(results, listFiles);
}
catch (error) {
core.setFailed(error.message);
}
}
function isPathInput(text) {
return !text.includes('\n');
@ -4580,108 +4638,94 @@ function getConfigFileContent(configPath) {
}
return fs.readFileSync(configPath, { encoding: 'utf8' });
}
function getChangedFiles(token) {
return __awaiter(this, void 0, void 0, function* () {
if (github.context.eventName === 'pull_request' || github.context.eventName === 'pull_request_target') {
const pr = github.context.payload.pull_request;
return token ? yield getChangedFilesFromApi(token, pr) : yield getChangedFilesFromGit(pr.base.sha);
}
else if (github.context.eventName === 'push') {
return getChangedFilesFromPush();
}
else {
throw new Error('This action can be triggered only by pull_request or push event');
}
});
}
function getChangedFilesFromPush() {
return __awaiter(this, void 0, void 0, function* () {
const push = github.context.payload;
// No change detection for pushed tags
if (git.isTagRef(push.ref)) {
core.info('Workflow is triggered by pushing of tag. Change detection will not run.');
return null;
}
// Get base from input or use repo default branch.
// It it starts with 'refs/', it will be trimmed (git fetch refs/heads/<NAME> doesn't work)
const baseInput = git.trimRefs(core.getInput('base', { required: false }) || push.repository.default_branch);
// If base references same branch it was pushed to, we will do comparison against the previously pushed commit.
// Otherwise changes are detected against the base reference
const base = git.trimRefsHeads(baseInput) === git.trimRefsHeads(push.ref) ? push.before : baseInput;
// There is no previous commit for comparison
// e.g. change detection against previous commit of just pushed new branch
if (base === git.NULL_SHA) {
core.info('There is no previous commit for comparison. Change detection will not run.');
return null;
}
return yield getChangedFilesFromGit(base);
});
}
// Fetch base branch and use `git diff` to determine changed files
function getChangedFilesFromGit(ref) {
return __awaiter(this, void 0, void 0, function* () {
return core.group(`Fetching base and using \`git diff-index\` to determine changed files`, () => __awaiter(this, void 0, void 0, function* () {
yield git.fetchCommit(ref);
// FETCH_HEAD will always point to the just fetched commit
// No matter if ref is SHA, branch or tag name or full git ref
return yield git.getChangedFiles(git.FETCH_HEAD);
}));
});
}
// Uses github REST api to get list of files changed in PR
function getChangedFilesFromApi(token, pullRequest) {
return __awaiter(this, void 0, void 0, function* () {
core.info(`Fetching list of changed files for PR#${pullRequest.number} from Github API`);
const client = new github.GitHub(token);
const pageSize = 100;
const files = [];
for (let page = 0; page * pageSize < pullRequest.changed_files; page++) {
const response = yield client.pulls.listFiles({
owner: github.context.repo.owner,
repo: github.context.repo.repo,
pull_number: pullRequest.number,
page,
per_page: pageSize
});
for (const row of response.data) {
// There's no obvious use-case for detection of renames
// Therefore we treat it as if rename detection in git diff was turned off.
// Rename is replaced by delete of original filename and add of new filename
if (row.status === file_1.ChangeStatus.Renamed) {
files.push({
filename: row.filename,
status: file_1.ChangeStatus.Added
});
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
});
}
else {
files.push({
filename: row.filename,
status: row.status
});
}
}
}
return files;
});
}
function exportNoMatchingResults(filter) {
core.info('All filters will be set to true but no matched files will be exported.');
for (const key of Object.keys(filter.rules)) {
core.setOutput(key, true);
async function getChangedFiles(token, base, initialFetchDepth) {
if (github.context.eventName === 'pull_request' || github.context.eventName === 'pull_request_target') {
const pr = github.context.payload.pull_request;
return token
? await getChangedFilesFromApi(token, pr)
: await git.getChangesSinceRef(pr.base.ref, initialFetchDepth);
}
else if (github.context.eventName === 'push') {
return getChangedFilesFromPush(base, initialFetchDepth);
}
else {
throw new Error('This action can be triggered only by pull_request, pull_request_target or push event');
}
}
async function getChangedFilesFromPush(base, initialFetchDepth) {
const push = github.context.payload;
// No change detection for pushed tags
if (git.isTagRef(push.ref)) {
core.info('Workflow is triggered by pushing of tag - all files will be listed as added');
return await git.listAllFilesAsAdded();
}
const baseRef = git.trimRefsHeads(base || push.repository.default_branch);
const pushRef = git.trimRefsHeads(push.ref);
// If base references same branch it was pushed to, we will do comparison against the previously pushed commit.
if (baseRef === pushRef) {
if (push.before === git.NULL_SHA) {
core.info('First push of a branch detected - all files will be listed as added');
return await git.listAllFilesAsAdded();
}
core.info(`Changes will be detected against the last previously pushed commit on same branch (${pushRef})`);
return await git.getChangesAgainstSha(push.before);
}
// Changes introduced by current branch against the base branch
core.info(`Changes will be detected against the branch ${baseRef}`);
return await git.getChangesSinceRef(baseRef, initialFetchDepth);
}
// Uses github REST api to get list of files changed in PR
async function getChangedFilesFromApi(token, pullRequest) {
core.info(`Fetching list of changed files for PR#${pullRequest.number} from Github API`);
const client = new github.GitHub(token);
const pageSize = 100;
const files = [];
for (let page = 0; page * pageSize < pullRequest.changed_files; page++) {
const response = await client.pulls.listFiles({
owner: github.context.repo.owner,
repo: github.context.repo.repo,
pull_number: pullRequest.number,
page,
per_page: pageSize
});
for (const row of response.data) {
// There's no obvious use-case for detection of renames
// Therefore we treat it as if rename detection in git diff was turned off.
// Rename is replaced by delete of original filename and add of new filename
if (row.status === file_1.ChangeStatus.Renamed) {
files.push({
filename: row.filename,
status: file_1.ChangeStatus.Added
});
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
});
}
else {
files.push({
filename: row.filename,
status: row.status
});
}
}
}
return files;
}
function exportResults(results, format) {
core.info('Results:');
for (const [key, files] of Object.entries(results)) {
const value = files.length > 0;
core.startGroup(`Filter ${key} = ${value}`);
core.info('Matching files:');
for (const file of files) {
core.info(`${file.filename} [${file.status}]`);
if (files.length > 0) {
core.info('Matching files:');
for (const file of files) {
core.info(`${file.filename} [${file.status}]`);
}
}
else {
core.info('Matching files: none');
}
core.setOutput(key, value);
if (format !== 'none') {

View File

@ -3,32 +3,81 @@ import * as core from '@actions/core'
import {File, ChangeStatus} from './file'
export const NULL_SHA = '0000000000000000000000000000000000000000'
export const FETCH_HEAD = 'FETCH_HEAD'
export async function fetchCommit(ref: string): Promise<void> {
const exitCode = await exec('git', ['fetch', '--depth=1', '--no-tags', 'origin', ref])
if (exitCode !== 0) {
throw new Error(`Fetching ${ref} failed`)
export async function getChangesAgainstSha(sha: string): Promise<File[]> {
// Fetch single commit
core.startGroup(`Fetching ${sha} from origin`)
await exec('git', ['fetch', '--depth=1', '--no-tags', 'origin', sha])
core.endGroup()
// Get differences between sha and HEAD
core.startGroup(`Change detection ${sha}..HEAD`)
let output = ''
try {
// Two dots '..' change detection - directly compares two versions
await exec('git', ['diff', '--no-renames', '--name-status', '-z', `${sha}..HEAD`], {
listeners: {
stdout: (data: Buffer) => (output += data.toString())
}
})
} finally {
fixStdOutNullTermination()
core.endGroup()
}
return parseGitDiffOutput(output)
}
export async function getChangedFiles(ref: string, cmd = exec): Promise<File[]> {
let output = ''
const exitCode = await cmd('git', ['diff-index', '--name-status', '-z', ref], {
listeners: {
stdout: (data: Buffer) => (output += data.toString())
}
})
export async function getChangesSinceRef(ref: string, initialFetchDepth: number): Promise<File[]> {
// Fetch and add base branch
core.startGroup(`Fetching ${ref} from origin until merge-base is found`)
await exec('git', ['fetch', `--depth=${initialFetchDepth}`, '--no-tags', 'origin', `${ref}:${ref}`])
if (exitCode !== 0) {
throw new Error(`Couldn't determine changed files`)
async function hasMergeBase(): Promise<boolean> {
return (await exec('git', ['merge-base', ref, 'HEAD'], {ignoreReturnCode: true})) === 0
}
// Previous command uses NULL as delimiters and output is printed to stdout.
// We have to make sure next thing written to stdout will start on new line.
// Otherwise things like ::set-output wouldn't work.
core.info('')
async function countCommits(): Promise<number> {
return (await getNumberOfCommits('HEAD')) + (await getNumberOfCommits(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', '--no-auto-gc', '-q'])
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()
}
lastCommitsCount = count
deepen = Math.min(deepen * 2, Number.MAX_SAFE_INTEGER)
} while (!(await hasMergeBase()))
}
core.endGroup()
// Get changes introduced on HEAD compared to ref
core.startGroup(`Change detection ${ref}...HEAD`)
let output = ''
try {
// Three dots '...' change detection - finds merge-base and compares against it
await exec('git', ['diff', '--no-renames', '--name-status', '-z', `${ref}...HEAD`], {
listeners: {
stdout: (data: Buffer) => (output += data.toString())
}
})
} finally {
fixStdOutNullTermination()
core.endGroup()
}
return parseGitDiffOutput(output)
}
export function parseGitDiffOutput(output: string): File[] {
const tokens = output.split('\u0000').filter(s => s.length > 0)
const files: File[] = []
for (let i = 0; i + 1 < tokens.length; i += 2) {
@ -40,6 +89,29 @@ export async function getChangedFiles(ref: string, cmd = exec): Promise<File[]>
return files
}
export async function listAllFilesAsAdded(): Promise<File[]> {
core.startGroup('Listing all files tracked by git')
let output = ''
try {
await exec('git', ['ls-files', '-z'], {
listeners: {
stdout: (data: Buffer) => (output += data.toString())
}
})
} finally {
fixStdOutNullTermination()
core.endGroup()
}
return output
.split('\u0000')
.filter(s => s.length > 0)
.map(path => ({
status: ChangeStatus.Added,
filename: path
}))
}
export function isTagRef(ref: string): boolean {
return ref.startsWith('refs/tags/')
}
@ -53,10 +125,28 @@ export function trimRefsHeads(ref: string): string {
return trimStart(trimRef, 'heads/')
}
async function getNumberOfCommits(ref: string): Promise<number> {
let output = ''
await exec('git', ['rev-list', `--count`, ref], {
listeners: {
stdout: (data: Buffer) => (output += data.toString())
}
})
const count = parseInt(output)
return isNaN(count) ? 0 : count
}
function trimStart(ref: string, start: string): string {
return ref.startsWith(start) ? ref.substr(start.length) : ref
}
function fixStdOutNullTermination(): void {
// Previous command uses NULL as delimiters and output is printed to stdout.
// We have to make sure next thing written to stdout will start on new line.
// Otherwise things like ::set-output wouldn't work.
core.info('')
}
const statusMap: {[char: string]: ChangeStatus} = {
A: ChangeStatus.Added,
C: ChangeStatus.Copied,

View File

@ -18,9 +18,11 @@ async function run(): Promise<void> {
}
const token = core.getInput('token', {required: false})
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 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}'`)
@ -28,15 +30,9 @@ async function run(): Promise<void> {
}
const filter = new Filter(filtersYaml)
const files = await getChangedFiles(token)
if (files === null) {
// Change detection was not possible
exportNoMatchingResults(filter)
} else {
const results = filter.match(files)
exportResults(results, listFiles)
}
const files = await getChangedFiles(token, base, initialFetchDepth)
const results = filter.match(files)
exportResults(results, listFiles)
} catch (error) {
core.setFailed(error.message)
}
@ -58,52 +54,45 @@ function getConfigFileContent(configPath: string): string {
return fs.readFileSync(configPath, {encoding: 'utf8'})
}
async function getChangedFiles(token: string): Promise<File[] | null> {
async function getChangedFiles(token: string, base: string, initialFetchDepth: number): Promise<File[]> {
if (github.context.eventName === 'pull_request' || github.context.eventName === 'pull_request_target') {
const pr = github.context.payload.pull_request as Webhooks.WebhookPayloadPullRequestPullRequest
return token ? await getChangedFilesFromApi(token, pr) : await getChangedFilesFromGit(pr.base.sha)
return token
? await getChangedFilesFromApi(token, pr)
: await git.getChangesSinceRef(pr.base.ref, initialFetchDepth)
} else if (github.context.eventName === 'push') {
return getChangedFilesFromPush()
return getChangedFilesFromPush(base, initialFetchDepth)
} else {
throw new Error('This action can be triggered only by pull_request or push event')
throw new Error('This action can be triggered only by pull_request, pull_request_target or push event')
}
}
async function getChangedFilesFromPush(): Promise<File[] | null> {
async function getChangedFilesFromPush(base: string, initialFetchDepth: number): Promise<File[]> {
const push = github.context.payload as Webhooks.WebhookPayloadPush
// No change detection for pushed tags
if (git.isTagRef(push.ref)) {
core.info('Workflow is triggered by pushing of tag. Change detection will not run.')
return null
core.info('Workflow is triggered by pushing of tag - all files will be listed as added')
return await git.listAllFilesAsAdded()
}
// Get base from input or use repo default branch.
// It it starts with 'refs/', it will be trimmed (git fetch refs/heads/<NAME> doesn't work)
const baseInput = git.trimRefs(core.getInput('base', {required: false}) || push.repository.default_branch)
const baseRef = git.trimRefsHeads(base || push.repository.default_branch)
const pushRef = git.trimRefsHeads(push.ref)
// If base references same branch it was pushed to, we will do comparison against the previously pushed commit.
// Otherwise changes are detected against the base reference
const base = git.trimRefsHeads(baseInput) === git.trimRefsHeads(push.ref) ? push.before : baseInput
if (baseRef === pushRef) {
if (push.before === git.NULL_SHA) {
core.info('First push of a branch detected - all files will be listed as added')
return await git.listAllFilesAsAdded()
}
// There is no previous commit for comparison
// e.g. change detection against previous commit of just pushed new branch
if (base === git.NULL_SHA) {
core.info('There is no previous commit for comparison. Change detection will not run.')
return null
core.info(`Changes will be detected against the last previously pushed commit on same branch (${pushRef})`)
return await git.getChangesAgainstSha(push.before)
}
return await getChangedFilesFromGit(base)
}
// Fetch base branch and use `git diff` to determine changed files
async function getChangedFilesFromGit(ref: string): Promise<File[]> {
return core.group(`Fetching base and using \`git diff-index\` to determine changed files`, async () => {
await git.fetchCommit(ref)
// FETCH_HEAD will always point to the just fetched commit
// No matter if ref is SHA, branch or tag name or full git ref
return await git.getChangedFiles(git.FETCH_HEAD)
})
// Changes introduced by current branch against the base branch
core.info(`Changes will be detected against the branch ${baseRef}`)
return await git.getChangesSinceRef(baseRef, initialFetchDepth)
}
// Uses github REST api to get list of files changed in PR
@ -149,20 +138,18 @@ async function getChangedFilesFromApi(
return files
}
function exportNoMatchingResults(filter: Filter): void {
core.info('All filters will be set to true but no matched files will be exported.')
for (const key of Object.keys(filter.rules)) {
core.setOutput(key, true)
}
}
function exportResults(results: FilterResults, format: ExportFormat): void {
core.info('Results:')
for (const [key, files] of Object.entries(results)) {
const value = files.length > 0
core.startGroup(`Filter ${key} = ${value}`)
core.info('Matching files:')
for (const file of files) {
core.info(`${file.filename} [${file.status}]`)
if (files.length > 0) {
core.info('Matching files:')
for (const file of files) {
core.info(`${file.filename} [${file.status}]`)
}
} else {
core.info('Matching files: none')
}
core.setOutput(key, value)

View File

@ -1,6 +1,6 @@
{
"compilerOptions": {
"target": "es6", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */
"target": "es2019", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */
"module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */
"outDir": "./lib", /* Redirect output structure to the directory. */
"rootDir": "./src", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */