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

@ -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,