mirror of
https://github.com/dorny/paths-filter.git
synced 2025-06-08 00:59:04 +00:00
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:
parent
3f845744aa
commit
81c90ccae8
7 changed files with 383 additions and 256 deletions
126
src/git.ts
126
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<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,
|
||||
|
|
81
src/main.ts
81
src/main.ts
|
@ -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)
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue