diff --git a/.github/workflows/pull-request-verification.yml b/.github/workflows/pull-request-verification.yml index f248d7c3..71fd0913 100644 --- a/.github/workflows/pull-request-verification.yml +++ b/.github/workflows/pull-request-verification.yml @@ -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: diff --git a/__tests__/git.test.ts b/__tests__/git.test.ts index f31b374f..ce46d6e0 100644 --- a/__tests__/git.test.ts +++ b/__tests__/git.test.ts @@ -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) diff --git a/action.yml b/action.yml index d8411a93..267d7355 100644 --- a/action.yml +++ b/action.yml @@ -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' diff --git a/dist/index.js b/dist/index.js index d3f584ae..418489df 100644 --- a/dist/index.js +++ b/dist/index.js @@ -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`); - } - }); +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()) + } + }); + } + finally { + fixStdOutNullTermination(); + core.endGroup(); + } + return parseGitDiffOutput(output); +} +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.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], { +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()) } }); - 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 output + .split('\u0000') + .filter(s => s.length > 0) + .map(path => ({ + status: file_1.ChangeStatus.Added, + filename: path + })); } -exports.getChangedFiles = getChangedFiles; +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); - } - } - catch (error) { - core.setFailed(error.message); +async function run() { + try { + const workingDirectory = core.getInput('working-directory', { required: false }); + if (workingDirectory) { + process.chdir(workingDirectory); + } + 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/ 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); - }); +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'); + } } -// 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); - })); - }); +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 -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 - }); - } +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 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); } + 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') { diff --git a/src/git.ts b/src/git.ts index b9695d5c..831ab6ac 100644 --- a/src/git.ts +++ b/src/git.ts @@ -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 { - 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 { + // 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 { - 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 { + // 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 { + 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 { + 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 return files } +export async function listAllFilesAsAdded(): Promise { + 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 { + 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, diff --git a/src/main.ts b/src/main.ts index 458833a8..f2a20d3a 100644 --- a/src/main.ts +++ b/src/main.ts @@ -18,9 +18,11 @@ async function run(): Promise { } 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 { } 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 { +async function getChangedFiles(token: string, base: string, initialFetchDepth: number): Promise { 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 { +async function getChangedFilesFromPush(base: string, initialFetchDepth: number): Promise { 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/ 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 - - // 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 - } + 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() + } - return await getChangedFilesFromGit(base) -} + core.info(`Changes will be detected against the last previously pushed commit on same branch (${pushRef})`) + return await git.getChangesAgainstSha(push.before) + } -// Fetch base branch and use `git diff` to determine changed files -async function getChangedFilesFromGit(ref: string): Promise { - 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) diff --git a/tsconfig.json b/tsconfig.json index f6e7cb5b..fa74d553 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -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. */