Read-only mirror — the public repositories of the Forgejo instance, refreshed every 10 minutes. The live instance (fg.dieschoenewolke.de) is reachable only over the VPN; clone here with https://git.dieschoenewolke.de/git/<owner>/<repo>.git.
summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/fs-helper.ts77
-rw-r--r--src/git-command-manager.ts399
-rw-r--r--src/git-source-provider.ts246
-rw-r--r--src/git-version.ts77
-rw-r--r--src/input-helper.ts104
-rw-r--r--src/main.ts47
-rw-r--r--src/misc/generate-docs.ts102
-rw-r--r--src/ref-helper.ts109
8 files changed, 1161 insertions, 0 deletions
diff --git a/src/fs-helper.ts b/src/fs-helper.ts
new file mode 100644
index 0000000..c443404
--- /dev/null
+++ b/src/fs-helper.ts
@@ -0,0 +1,77 @@
+import * as fs from 'fs'
+
+export function directoryExistsSync(path: string, required?: boolean): boolean {
+ if (!path) {
+ throw new Error("Arg 'path' must not be empty")
+ }
+
+ let stats: fs.Stats
+ try {
+ stats = fs.statSync(path)
+ } catch (error) {
+ if (error.code === 'ENOENT') {
+ if (!required) {
+ return false
+ }
+
+ throw new Error(`Directory '${path}' does not exist`)
+ }
+
+ throw new Error(
+ `Encountered an error when checking whether path '${path}' exists: ${error.message}`
+ )
+ }
+
+ if (stats.isDirectory()) {
+ return true
+ } else if (!required) {
+ return false
+ }
+
+ throw new Error(`Directory '${path}' does not exist`)
+}
+
+export function existsSync(path: string): boolean {
+ if (!path) {
+ throw new Error("Arg 'path' must not be empty")
+ }
+
+ try {
+ fs.statSync(path)
+ } catch (error) {
+ if (error.code === 'ENOENT') {
+ return false
+ }
+
+ throw new Error(
+ `Encountered an error when checking whether path '${path}' exists: ${error.message}`
+ )
+ }
+
+ return true
+}
+
+export function fileExistsSync(path: string): boolean {
+ if (!path) {
+ throw new Error("Arg 'path' must not be empty")
+ }
+
+ let stats: fs.Stats
+ try {
+ stats = fs.statSync(path)
+ } catch (error) {
+ if (error.code === 'ENOENT') {
+ return false
+ }
+
+ throw new Error(
+ `Encountered an error when checking whether path '${path}' exists: ${error.message}`
+ )
+ }
+
+ if (!stats.isDirectory()) {
+ return true
+ }
+
+ return false
+}
diff --git a/src/git-command-manager.ts b/src/git-command-manager.ts
new file mode 100644
index 0000000..6e4f22f
--- /dev/null
+++ b/src/git-command-manager.ts
@@ -0,0 +1,399 @@
+import * as core from '@actions/core'
+import * as exec from '@actions/exec'
+import * as fshelper from './fs-helper'
+import * as io from '@actions/io'
+import * as path from 'path'
+import {GitVersion} from './git-version'
+
+export interface IGitCommandManager {
+ branchDelete(remote: boolean, branch: string): Promise<void>
+ branchExists(remote: boolean, pattern: string): Promise<boolean>
+ branchList(remote: boolean): Promise<string[]>
+ checkout(ref: string, startPoint: string): Promise<void>
+ checkoutDetach(): Promise<void>
+ config(configKey: string, configValue: string): Promise<void>
+ configExists(configKey: string): Promise<boolean>
+ fetch(fetchDepth: number, refSpec: string[]): Promise<void>
+ getWorkingDirectory(): string
+ init(): Promise<void>
+ isDetached(): Promise<boolean>
+ lfsFetch(ref: string): Promise<void>
+ lfsInstall(): Promise<void>
+ log1(): Promise<void>
+ remoteAdd(remoteName: string, remoteUrl: string): Promise<void>
+ tagExists(pattern: string): Promise<boolean>
+ tryClean(): Promise<boolean>
+ tryConfigUnset(configKey: string): Promise<boolean>
+ tryDisableAutomaticGarbageCollection(): Promise<boolean>
+ tryGetFetchUrl(): Promise<string>
+ tryReset(): Promise<boolean>
+}
+
+export async function CreateCommandManager(
+ workingDirectory: string,
+ lfs: boolean
+): Promise<IGitCommandManager> {
+ return await GitCommandManager.createCommandManager(workingDirectory, lfs)
+}
+
+class GitCommandManager {
+ private gitEnv = {
+ GIT_TERMINAL_PROMPT: '0', // Disable git prompt
+ GCM_INTERACTIVE: 'Never' // Disable prompting for git credential manager
+ }
+ private gitPath = ''
+ private lfs = false
+ private workingDirectory = ''
+
+ // Private constructor; use createCommandManager()
+ private constructor() {}
+
+ async branchDelete(remote: boolean, branch: string): Promise<void> {
+ const args = ['branch', '--delete', '--force']
+ if (remote) {
+ args.push('--remote')
+ }
+ args.push(branch)
+
+ await this.execGit(args)
+ }
+
+ async branchExists(remote: boolean, pattern: string): Promise<boolean> {
+ const args = ['branch', '--list']
+ if (remote) {
+ args.push('--remote')
+ }
+ args.push(pattern)
+
+ const output = await this.execGit(args)
+ return !!output.stdout.trim()
+ }
+
+ async branchList(remote: boolean): Promise<string[]> {
+ const result: string[] = []
+
+ // Note, this implementation uses "rev-parse --symbolic" because the output from
+ // "branch --list" is more difficult when in a detached HEAD state.
+
+ const args = ['rev-parse', '--symbolic']
+ if (remote) {
+ args.push('--remotes=origin')
+ } else {
+ args.push('--branches')
+ }
+
+ const output = await this.execGit(args)
+
+ for (let branch of output.stdout.trim().split('\n')) {
+ branch = branch.trim()
+ if (branch) {
+ result.push(branch)
+ }
+ }
+
+ return result
+ }
+
+ async checkout(ref: string, startPoint: string): Promise<void> {
+ const args = ['checkout', '--progress', '--force']
+ if (startPoint) {
+ args.push('-B', ref, startPoint)
+ } else {
+ args.push(ref)
+ }
+
+ await this.execGit(args)
+ }
+
+ async checkoutDetach(): Promise<void> {
+ const args = ['checkout', '--detach']
+ await this.execGit(args)
+ }
+
+ async config(configKey: string, configValue: string): Promise<void> {
+ await this.execGit(['config', configKey, configValue])
+ }
+
+ async configExists(configKey: string): Promise<boolean> {
+ const pattern = configKey.replace(/[^a-zA-Z0-9_]/g, x => {
+ return `\\${x}`
+ })
+ const output = await this.execGit(
+ ['config', '--name-only', '--get-regexp', pattern],
+ true
+ )
+ return output.exitCode === 0
+ }
+
+ async fetch(fetchDepth: number, refSpec: string[]): Promise<void> {
+ const args = [
+ '-c',
+ 'protocol.version=2',
+ 'fetch',
+ '--no-tags',
+ '--prune',
+ '--progress',
+ '--no-recurse-submodules'
+ ]
+ if (fetchDepth > 0) {
+ args.push(`--depth=${fetchDepth}`)
+ } else if (
+ fshelper.fileExistsSync(
+ path.join(this.workingDirectory, '.git', 'shallow')
+ )
+ ) {
+ args.push('--unshallow')
+ }
+
+ args.push('origin')
+ for (const arg of refSpec) {
+ args.push(arg)
+ }
+
+ let attempt = 1
+ const maxAttempts = 3
+ while (attempt <= maxAttempts) {
+ const allowAllExitCodes = attempt < maxAttempts
+ const output = await this.execGit(args, allowAllExitCodes)
+ if (output.exitCode === 0) {
+ break
+ }
+
+ const seconds = this.getRandomIntInclusive(1, 10)
+ core.warning(
+ `Git fetch failed with exit code ${output.exitCode}. Waiting ${seconds} seconds before trying again.`
+ )
+ await this.sleep(seconds * 1000)
+ attempt++
+ }
+ }
+
+ getWorkingDirectory(): string {
+ return this.workingDirectory
+ }
+
+ async init(): Promise<void> {
+ await this.execGit(['init', this.workingDirectory])
+ }
+
+ async isDetached(): Promise<boolean> {
+ // Note, this implementation uses "branch --show-current" because
+ // "rev-parse --symbolic-full-name HEAD" can fail on a new repo
+ // with nothing checked out.
+
+ const output = await this.execGit(['branch', '--show-current'])
+ return output.stdout.trim() === ''
+ }
+
+ async lfsFetch(ref: string): Promise<void> {
+ const args = ['lfs', 'fetch', 'origin', ref]
+
+ let attempt = 1
+ const maxAttempts = 3
+ while (attempt <= maxAttempts) {
+ const allowAllExitCodes = attempt < maxAttempts
+ const output = await this.execGit(args, allowAllExitCodes)
+ if (output.exitCode === 0) {
+ break
+ }
+
+ const seconds = this.getRandomIntInclusive(1, 10)
+ core.warning(
+ `Git lfs fetch failed with exit code ${output.exitCode}. Waiting ${seconds} seconds before trying again.`
+ )
+ await this.sleep(seconds * 1000)
+ attempt++
+ }
+ }
+
+ async lfsInstall(): Promise<void> {
+ await this.execGit(['lfs', 'install', '--local'])
+ }
+
+ async log1(): Promise<void> {
+ await this.execGit(['log', '-1'])
+ }
+
+ async remoteAdd(remoteName: string, remoteUrl: string): Promise<void> {
+ await this.execGit(['remote', 'add', remoteName, remoteUrl])
+ }
+
+ async tagExists(pattern: string): Promise<boolean> {
+ const output = await this.execGit(['tag', '--list', pattern])
+ return !!output.stdout.trim()
+ }
+
+ async tryClean(): Promise<boolean> {
+ const output = await this.execGit(['clean', '-ffdx'], true)
+ return output.exitCode === 0
+ }
+
+ async tryConfigUnset(configKey: string): Promise<boolean> {
+ const output = await this.execGit(
+ ['config', '--unset-all', configKey],
+ true
+ )
+ return output.exitCode === 0
+ }
+
+ async tryDisableAutomaticGarbageCollection(): Promise<boolean> {
+ const output = await this.execGit(['config', 'gc.auto', '0'], true)
+ return output.exitCode === 0
+ }
+
+ async tryGetFetchUrl(): Promise<string> {
+ const output = await this.execGit(
+ ['config', '--get', 'remote.origin.url'],
+ true
+ )
+
+ if (output.exitCode !== 0) {
+ return ''
+ }
+
+ const stdout = output.stdout.trim()
+ if (stdout.includes('\n')) {
+ return ''
+ }
+
+ return stdout
+ }
+
+ async tryReset(): Promise<boolean> {
+ const output = await this.execGit(['reset', '--hard', 'HEAD'], true)
+ return output.exitCode === 0
+ }
+
+ static async createCommandManager(
+ workingDirectory: string,
+ lfs: boolean
+ ): Promise<GitCommandManager> {
+ const result = new GitCommandManager()
+ await result.initializeCommandManager(workingDirectory, lfs)
+ return result
+ }
+
+ private async execGit(
+ args: string[],
+ allowAllExitCodes = false
+ ): Promise<GitOutput> {
+ fshelper.directoryExistsSync(this.workingDirectory, true)
+
+ const result = new GitOutput()
+
+ const env = {}
+ for (const key of Object.keys(process.env)) {
+ env[key] = process.env[key]
+ }
+ for (const key of Object.keys(this.gitEnv)) {
+ env[key] = this.gitEnv[key]
+ }
+
+ const stdout: string[] = []
+
+ const options = {
+ cwd: this.workingDirectory,
+ env,
+ ignoreReturnCode: allowAllExitCodes,
+ listeners: {
+ stdout: (data: Buffer) => {
+ stdout.push(data.toString())
+ }
+ }
+ }
+
+ result.exitCode = await exec.exec(`"${this.gitPath}"`, args, options)
+ result.stdout = stdout.join('')
+ return result
+ }
+
+ private async initializeCommandManager(
+ workingDirectory: string,
+ lfs: boolean
+ ): Promise<void> {
+ this.workingDirectory = workingDirectory
+
+ // Git-lfs will try to pull down assets if any of the local/user/system setting exist.
+ // If the user didn't enable `LFS` in their pipeline definition, disable LFS fetch/checkout.
+ this.lfs = lfs
+ if (!this.lfs) {
+ this.gitEnv['GIT_LFS_SKIP_SMUDGE'] = '1'
+ }
+
+ this.gitPath = await io.which('git', true)
+
+ // Git version
+ core.debug('Getting git version')
+ let gitVersion = new GitVersion()
+ let gitOutput = await this.execGit(['version'])
+ let stdout = gitOutput.stdout.trim()
+ if (!stdout.includes('\n')) {
+ const match = stdout.match(/\d+\.\d+(\.\d+)?/)
+ if (match) {
+ gitVersion = new GitVersion(match[0])
+ }
+ }
+ if (!gitVersion.isValid()) {
+ throw new Error('Unable to determine git version')
+ }
+
+ // Minimum git version
+ // Note:
+ // - Auth header not supported before 2.9
+ // - Wire protocol v2 not supported before 2.18
+ const minimumGitVersion = new GitVersion('2.18')
+ if (!gitVersion.checkMinimum(minimumGitVersion)) {
+ throw new Error(
+ `Minimum required git version is ${minimumGitVersion}. Your git ('${this.gitPath}') is ${gitVersion}`
+ )
+ }
+
+ if (this.lfs) {
+ // Git-lfs version
+ core.debug('Getting git-lfs version')
+ let gitLfsVersion = new GitVersion()
+ const gitLfsPath = await io.which('git-lfs', true)
+ gitOutput = await this.execGit(['lfs', 'version'])
+ stdout = gitOutput.stdout.trim()
+ if (!stdout.includes('\n')) {
+ const match = stdout.match(/\d+\.\d+(\.\d+)?/)
+ if (match) {
+ gitLfsVersion = new GitVersion(match[0])
+ }
+ }
+ if (!gitLfsVersion.isValid()) {
+ throw new Error('Unable to determine git-lfs version')
+ }
+
+ // Minimum git-lfs version
+ // Note:
+ // - Auth header not supported before 2.1
+ const minimumGitLfsVersion = new GitVersion('2.1')
+ if (!gitLfsVersion.checkMinimum(minimumGitLfsVersion)) {
+ throw new Error(
+ `Minimum required git-lfs version is ${minimumGitLfsVersion}. Your git-lfs ('${gitLfsPath}') is ${gitLfsVersion}`
+ )
+ }
+ }
+
+ // Set the user agent
+ const gitHttpUserAgent = `git/${gitVersion} (github-actions-checkout)`
+ core.debug(`Set git useragent to: ${gitHttpUserAgent}`)
+ this.gitEnv['GIT_HTTP_USER_AGENT'] = gitHttpUserAgent
+ }
+
+ private getRandomIntInclusive(minimum: number, maximum: number): number {
+ minimum = Math.floor(minimum)
+ maximum = Math.floor(maximum)
+ return Math.floor(Math.random() * (maximum - minimum + 1)) + minimum
+ }
+
+ private async sleep(milliseconds): Promise<void> {
+ return new Promise(resolve => setTimeout(resolve, milliseconds))
+ }
+}
+
+class GitOutput {
+ stdout = ''
+ exitCode = 0
+}
diff --git a/src/git-source-provider.ts b/src/git-source-provider.ts
new file mode 100644
index 0000000..0752100
--- /dev/null
+++ b/src/git-source-provider.ts
@@ -0,0 +1,246 @@
+import * as core from '@actions/core'
+import * as coreCommand from '@actions/core/lib/command'
+import * as fs from 'fs'
+import * as fsHelper from './fs-helper'
+import * as gitCommandManager from './git-command-manager'
+import * as io from '@actions/io'
+import * as path from 'path'
+import * as refHelper from './ref-helper'
+import {IGitCommandManager} from './git-command-manager'
+
+const authConfigKey = `http.https://github.com/.extraheader`
+
+export interface ISourceSettings {
+ repositoryPath: string
+ repositoryOwner: string
+ repositoryName: string
+ ref: string
+ commit: string
+ clean: boolean
+ fetchDepth: number
+ lfs: boolean
+ accessToken: string
+}
+
+export async function getSource(settings: ISourceSettings): Promise<void> {
+ core.info(
+ `Syncing repository: ${settings.repositoryOwner}/${settings.repositoryName}`
+ )
+ const repositoryUrl = `https://github.com/${encodeURIComponent(
+ settings.repositoryOwner
+ )}/${encodeURIComponent(settings.repositoryName)}`
+
+ // Remove conflicting file path
+ if (fsHelper.fileExistsSync(settings.repositoryPath)) {
+ await io.rmRF(settings.repositoryPath)
+ }
+
+ // Create directory
+ let isExisting = true
+ if (!fsHelper.directoryExistsSync(settings.repositoryPath)) {
+ isExisting = false
+ await io.mkdirP(settings.repositoryPath)
+ }
+
+ // Git command manager
+ core.info(`Working directory is '${settings.repositoryPath}'`)
+ const git = await gitCommandManager.CreateCommandManager(
+ settings.repositoryPath,
+ settings.lfs
+ )
+
+ // Try prepare existing directory, otherwise recreate
+ if (
+ isExisting &&
+ !(await tryPrepareExistingDirectory(
+ git,
+ settings.repositoryPath,
+ repositoryUrl,
+ settings.clean
+ ))
+ ) {
+ await io.rmRF(settings.repositoryPath)
+ await io.mkdirP(settings.repositoryPath)
+ }
+
+ // Initialize the repository
+ if (
+ !fsHelper.directoryExistsSync(path.join(settings.repositoryPath, '.git'))
+ ) {
+ await git.init()
+ await git.remoteAdd('origin', repositoryUrl)
+ }
+
+ // Disable automatic garbage collection
+ if (!(await git.tryDisableAutomaticGarbageCollection())) {
+ core.warning(
+ `Unable to turn off git automatic garbage collection. The git fetch operation may trigger garbage collection and cause a delay.`
+ )
+ }
+
+ // Remove possible previous extraheader
+ await removeGitConfig(git, authConfigKey)
+
+ // Add extraheader (auth)
+ const base64Credentials = Buffer.from(
+ `x-access-token:${settings.accessToken}`,
+ 'utf8'
+ ).toString('base64')
+ core.setSecret(base64Credentials)
+ const authConfigValue = `AUTHORIZATION: basic ${base64Credentials}`
+ await git.config(authConfigKey, authConfigValue)
+
+ // LFS install
+ if (settings.lfs) {
+ await git.lfsInstall()
+ }
+
+ // Fetch
+ const refSpec = refHelper.getRefSpec(settings.ref, settings.commit)
+ await git.fetch(settings.fetchDepth, refSpec)
+
+ // Checkout info
+ const checkoutInfo = await refHelper.getCheckoutInfo(
+ git,
+ settings.ref,
+ settings.commit
+ )
+
+ // LFS fetch
+ // Explicit lfs-fetch to avoid slow checkout (fetches one lfs object at a time).
+ // Explicit lfs fetch will fetch lfs objects in parallel.
+ if (settings.lfs) {
+ await git.lfsFetch(checkoutInfo.startPoint || checkoutInfo.ref)
+ }
+
+ // Checkout
+ await git.checkout(checkoutInfo.ref, checkoutInfo.startPoint)
+
+ // Dump some info about the checked out commit
+ await git.log1()
+
+ // Set intra-task state for cleanup
+ coreCommand.issueCommand(
+ 'save-state',
+ {name: 'repositoryPath'},
+ settings.repositoryPath
+ )
+}
+
+export async function cleanup(repositoryPath: string): Promise<void> {
+ // Repo exists?
+ if (!fsHelper.fileExistsSync(path.join(repositoryPath, '.git', 'config'))) {
+ return
+ }
+ fsHelper.directoryExistsSync(repositoryPath, true)
+
+ // Remove the config key
+ const git = await gitCommandManager.CreateCommandManager(
+ repositoryPath,
+ false
+ )
+ await removeGitConfig(git, authConfigKey)
+}
+
+async function tryPrepareExistingDirectory(
+ git: IGitCommandManager,
+ repositoryPath: string,
+ repositoryUrl: string,
+ clean: boolean
+): Promise<boolean> {
+ // Fetch URL does not match
+ if (
+ !fsHelper.directoryExistsSync(path.join(repositoryPath, '.git')) ||
+ repositoryUrl !== (await git.tryGetFetchUrl())
+ ) {
+ return false
+ }
+
+ // Delete any index.lock and shallow.lock left by a previously canceled run or crashed git process
+ const lockPaths = [
+ path.join(repositoryPath, '.git', 'index.lock'),
+ path.join(repositoryPath, '.git', 'shallow.lock')
+ ]
+ for (const lockPath of lockPaths) {
+ try {
+ await io.rmRF(lockPath)
+ } catch (error) {
+ core.debug(`Unable to delete '${lockPath}'. ${error.message}`)
+ }
+ }
+
+ try {
+ // Checkout detached HEAD
+ if (!(await git.isDetached())) {
+ await git.checkoutDetach()
+ }
+
+ // Remove all refs/heads/*
+ let branches = await git.branchList(false)
+ for (const branch of branches) {
+ await git.branchDelete(false, branch)
+ }
+
+ // Remove all refs/remotes/origin/* to avoid conflicts
+ branches = await git.branchList(true)
+ for (const branch of branches) {
+ await git.branchDelete(true, branch)
+ }
+ } catch (error) {
+ core.warning(
+ `Unable to prepare the existing repository. The repository will be recreated instead.`
+ )
+ return false
+ }
+
+ // Clean
+ if (clean) {
+ let succeeded = true
+ if (!(await git.tryClean())) {
+ core.debug(
+ `The clean command failed. This might be caused by: 1) path too long, 2) permission issue, or 3) file in use. For futher investigation, manually run 'git clean -ffdx' on the directory '${repositoryPath}'.`
+ )
+ succeeded = false
+ } else if (!(await git.tryReset())) {
+ succeeded = false
+ }
+
+ if (!succeeded) {
+ core.warning(
+ `Unable to clean or reset the repository. The repository will be recreated instead.`
+ )
+ }
+
+ return succeeded
+ }
+
+ return true
+}
+
+async function removeGitConfig(
+ git: IGitCommandManager,
+ configKey: string
+): Promise<void> {
+ if (
+ (await git.configExists(configKey)) &&
+ !(await git.tryConfigUnset(configKey))
+ ) {
+ // Load the config contents
+ core.warning(
+ `Failed to remove '${configKey}' from the git config. Attempting to remove the config value by editing the file directly.`
+ )
+ const configPath = path.join(git.getWorkingDirectory(), '.git', 'config')
+ fsHelper.fileExistsSync(configPath)
+ let contents = fs.readFileSync(configPath).toString() || ''
+
+ // Filter - only includes lines that do not contain the config key
+ const upperConfigKey = configKey.toUpperCase()
+ const split = contents
+ .split('\n')
+ .filter(x => !x.toUpperCase().includes(upperConfigKey))
+ contents = split.join('\n')
+
+ // Rewrite the config file
+ fs.writeFileSync(configPath, contents)
+ }
+}
diff --git a/src/git-version.ts b/src/git-version.ts
new file mode 100644
index 0000000..44bee1a
--- /dev/null
+++ b/src/git-version.ts
@@ -0,0 +1,77 @@
+export class GitVersion {
+ private readonly major: number = NaN
+ private readonly minor: number = NaN
+ private readonly patch: number = NaN
+
+ /**
+ * Used for comparing the version of git and git-lfs against the minimum required version
+ * @param version the version string, e.g. 1.2 or 1.2.3
+ */
+ constructor(version?: string) {
+ if (version) {
+ const match = version.match(/^(\d+)\.(\d+)(\.(\d+))?$/)
+ if (match) {
+ this.major = Number(match[1])
+ this.minor = Number(match[2])
+ if (match[4]) {
+ this.patch = Number(match[4])
+ }
+ }
+ }
+ }
+
+ /**
+ * Compares the instance against a minimum required version
+ * @param minimum Minimum version
+ */
+ checkMinimum(minimum: GitVersion): boolean {
+ if (!minimum.isValid()) {
+ throw new Error('Arg minimum is not a valid version')
+ }
+
+ // Major is insufficient
+ if (this.major < minimum.major) {
+ return false
+ }
+
+ // Major is equal
+ if (this.major === minimum.major) {
+ // Minor is insufficient
+ if (this.minor < minimum.minor) {
+ return false
+ }
+
+ // Minor is equal
+ if (this.minor === minimum.minor) {
+ // Patch is insufficient
+ if (this.patch && this.patch < (minimum.patch || 0)) {
+ return false
+ }
+ }
+ }
+
+ return true
+ }
+
+ /**
+ * Indicates whether the instance was constructed from a valid version string
+ */
+ isValid(): boolean {
+ return !isNaN(this.major)
+ }
+
+ /**
+ * Returns the version as a string, e.g. 1.2 or 1.2.3
+ */
+ toString(): string {
+ let result = ''
+ if (this.isValid()) {
+ result = `${this.major}.${this.minor}`
+ if (!isNaN(this.patch)) {
+ result += `.${this.patch}`
+ }
+ }
+
+ return result
+ }
+}
diff --git a/src/input-helper.ts b/src/input-helper.ts
new file mode 100644
index 0000000..d7d8779
--- /dev/null
+++ b/src/input-helper.ts
@@ -0,0 +1,104 @@
+import * as core from '@actions/core'
+import * as fsHelper from './fs-helper'
+import * as github from '@actions/github'
+import * as path from 'path'
+import {ISourceSettings} from './git-source-provider'
+
+export function getInputs(): ISourceSettings {
+ const result = ({} as unknown) as ISourceSettings
+
+ // GitHub workspace
+ let githubWorkspacePath = process.env['GITHUB_WORKSPACE']
+ if (!githubWorkspacePath) {
+ throw new Error('GITHUB_WORKSPACE not defined')
+ }
+ githubWorkspacePath = path.resolve(githubWorkspacePath)
+ core.debug(`GITHUB_WORKSPACE = '${githubWorkspacePath}'`)
+ fsHelper.directoryExistsSync(githubWorkspacePath, true)
+
+ // Qualified repository
+ const qualifiedRepository =
+ core.getInput('repository') ||
+ `${github.context.repo.owner}/${github.context.repo.repo}`
+ core.debug(`qualified repository = '${qualifiedRepository}'`)
+ const splitRepository = qualifiedRepository.split('/')
+ if (
+ splitRepository.length !== 2 ||
+ !splitRepository[0] ||
+ !splitRepository[1]
+ ) {
+ throw new Error(
+ `Invalid repository '${qualifiedRepository}'. Expected format {owner}/{repo}.`
+ )
+ }
+ result.repositoryOwner = splitRepository[0]
+ result.repositoryName = splitRepository[1]
+
+ // Repository path
+ result.repositoryPath = core.getInput('path') || '.'
+ result.repositoryPath = path.resolve(
+ githubWorkspacePath,
+ result.repositoryPath
+ )
+ if (
+ !(result.repositoryPath + path.sep).startsWith(
+ githubWorkspacePath + path.sep
+ )
+ ) {
+ throw new Error(
+ `Repository path '${result.repositoryPath}' is not under '${githubWorkspacePath}'`
+ )
+ }
+
+ // Workflow repository?
+ const isWorkflowRepository =
+ qualifiedRepository.toUpperCase() ===
+ `${github.context.repo.owner}/${github.context.repo.repo}`.toUpperCase()
+
+ // Source branch, source version
+ result.ref = core.getInput('ref')
+ if (!result.ref) {
+ if (isWorkflowRepository) {
+ result.ref = github.context.ref
+ result.commit = github.context.sha
+ }
+
+ if (!result.ref && !result.commit) {
+ result.ref = 'refs/heads/master'
+ }
+ }
+ // SHA?
+ else if (result.ref.match(/^[0-9a-fA-F]{40}$/)) {
+ result.commit = result.ref
+ result.ref = ''
+ }
+ core.debug(`ref = '${result.ref}'`)
+ core.debug(`commit = '${result.commit}'`)
+
+ // Clean
+ result.clean = (core.getInput('clean') || 'true').toUpperCase() === 'TRUE'
+ core.debug(`clean = ${result.clean}`)
+
+ // Submodules
+ if (core.getInput('submodules')) {
+ throw new Error(
+ "The input 'submodules' is not supported in actions/checkout@v2"
+ )
+ }
+
+ // Fetch depth
+ result.fetchDepth = Math.floor(Number(core.getInput('fetch-depth') || '1'))
+ if (isNaN(result.fetchDepth) || result.fetchDepth < 0) {
+ result.fetchDepth = 0
+ }
+ core.debug(`fetch depth = ${result.fetchDepth}`)
+
+ // LFS
+ result.lfs = (core.getInput('lfs') || 'false').toUpperCase() === 'TRUE'
+ core.debug(`lfs = ${result.lfs}`)
+
+ // Access token
+ result.accessToken = core.getInput('token')
+
+ return result
+}
diff --git a/src/main.ts b/src/main.ts
new file mode 100644
index 0000000..26da3aa
--- /dev/null
+++ b/src/main.ts
@@ -0,0 +1,47 @@
+import * as core from '@actions/core'
+import * as coreCommand from '@actions/core/lib/command'
+import * as gitSourceProvider from './git-source-provider'
+import * as inputHelper from './input-helper'
+import * as path from 'path'
+
+const cleanupRepositoryPath = process.env['STATE_repositoryPath'] as string
+
+async function run(): Promise<void> {
+ try {
+ const sourceSettings = inputHelper.getInputs()
+
+ try {
+ // Register problem matcher
+ coreCommand.issueCommand(
+ 'add-matcher',
+ {},
+ path.join(__dirname, 'problem-matcher.json')
+ )
+
+ // Get sources
+ await gitSourceProvider.getSource(sourceSettings)
+ } finally {
+ // Unregister problem matcher
+ coreCommand.issueCommand('remove-matcher', {owner: 'checkout-git'}, '')
+ }
+ } catch (error) {
+ core.setFailed(error.message)
+ }
+}
+
+async function cleanup(): Promise<void> {
+ try {
+ await gitSourceProvider.cleanup(cleanupRepositoryPath)
+ } catch (error) {
+ core.warning(error.message)
+ }
+}
+
+// Main
+if (!cleanupRepositoryPath) {
+ run()
+}
+// Post
+else {
+ cleanup()
+}
diff --git a/src/misc/generate-docs.ts b/src/misc/generate-docs.ts
new file mode 100644
index 0000000..c882c55
--- /dev/null
+++ b/src/misc/generate-docs.ts
@@ -0,0 +1,102 @@
+import * as fs from 'fs'
+import * as os from 'os'
+import * as path from 'path'
+import * as yaml from 'js-yaml'
+
+//
+// SUMMARY
+//
+// This script rebuilds the usage section in the README.md to be consistent with the action.yml
+
+function updateUsage(
+ actionReference: string,
+ actionYamlPath: string = 'action.yml',
+ readmePath: string = 'README.md',
+ startToken: string = '<!-- start usage -->',
+ endToken: string = '<!-- end usage -->'
+): void {
+ if (!actionReference) {
+ throw new Error('Parameter actionReference must not be empty')
+ }
+
+ // Load the action.yml
+ const actionYaml = yaml.safeLoad(fs.readFileSync(actionYamlPath).toString())
+
+ // Load the README
+ const originalReadme = fs.readFileSync(readmePath).toString()
+
+ // Find the start token
+ const startTokenIndex = originalReadme.indexOf(startToken)
+ if (startTokenIndex < 0) {
+ throw new Error(`Start token '${startToken}' not found`)
+ }
+
+ // Find the end token
+ const endTokenIndex = originalReadme.indexOf(endToken)
+ if (endTokenIndex < 0) {
+ throw new Error(`End token '${endToken}' not found`)
+ } else if (endTokenIndex < startTokenIndex) {
+ throw new Error('Start token must appear before end token')
+ }
+
+ // Build the new README
+ const newReadme: string[] = []
+
+ // Append the beginning
+ newReadme.push(originalReadme.substr(0, startTokenIndex + startToken.length))
+
+ // Build the new usage section
+ newReadme.push('```yaml', `- uses: ${actionReference}`, ' with:')
+ const inputs = actionYaml.inputs
+ let firstInput = true
+ for (const key of Object.keys(inputs)) {
+ const input = inputs[key]
+
+ // Line break between inputs
+ if (!firstInput) {
+ newReadme.push('')
+ }
+
+ // Constrain the width of the description
+ const width = 80
+ let description = input.description as string
+ while (description) {
+ // Longer than width? Find a space to break apart
+ let segment: string = description
+ if (description.length > width) {
+ segment = description.substr(0, width + 1)
+ while (!segment.endsWith(' ')) {
+ segment = segment.substr(0, segment.length - 1)
+ }
+ } else {
+ segment = description
+ }
+
+ description = description.substr(segment.length) // Remaining
+ segment = segment.trimRight() // Trim the trailing space
+ newReadme.push(` # ${segment}`)
+ }
+
+ // Input and default
+ if (input.default !== undefined) {
+ newReadme.push(` # Default: ${input.default}`)
+ }
+ newReadme.push(` ${key}: ''`)
+
+ firstInput = false
+ }
+
+ newReadme.push('```')
+
+ // Append the end
+ newReadme.push(originalReadme.substr(endTokenIndex))
+
+ // Write the new README
+ fs.writeFileSync(readmePath, newReadme.join(os.EOL))
+}
+
+updateUsage(
+ 'actions/checkout@preview',
+ path.join(__dirname, '..', '..', 'action.yml'),
+ path.join(__dirname, '..', '..', 'README.md')
+)
diff --git a/src/ref-helper.ts b/src/ref-helper.ts
new file mode 100644
index 0000000..ff256af
--- /dev/null
+++ b/src/ref-helper.ts
@@ -0,0 +1,109 @@
+import {IGitCommandManager} from './git-command-manager'
+
+export interface ICheckoutInfo {
+ ref: string
+ startPoint: string
+}
+
+export async function getCheckoutInfo(
+ git: IGitCommandManager,
+ ref: string,
+ commit: string
+): Promise<ICheckoutInfo> {
+ if (!git) {
+ throw new Error('Arg git cannot be empty')
+ }
+
+ if (!ref && !commit) {
+ throw new Error('Args ref and commit cannot both be empty')
+ }
+
+ const result = ({} as unknown) as ICheckoutInfo
+ const upperRef = (ref || '').toUpperCase()
+
+ // SHA only
+ if (!ref) {
+ result.ref = commit
+ }
+ // refs/heads/
+ else if (upperRef.startsWith('REFS/HEADS/')) {
+ const branch = ref.substring('refs/heads/'.length)
+ result.ref = branch
+ result.startPoint = `refs/remotes/origin/${branch}`
+ }
+ // refs/pull/
+ else if (upperRef.startsWith('REFS/PULL/')) {
+ const branch = ref.substring('refs/pull/'.length)
+ result.ref = `refs/remotes/pull/${branch}`
+ }
+ // refs/tags/
+ else if (upperRef.startsWith('REFS/')) {
+ result.ref = ref
+ }
+ // Unqualified ref, check for a matching branch or tag
+ else {
+ if (await git.branchExists(true, `origin/${ref}`)) {
+ result.ref = ref
+ result.startPoint = `refs/remotes/origin/${ref}`
+ } else if (await git.tagExists(`${ref}`)) {
+ result.ref = `refs/tags/${ref}`
+ } else {
+ throw new Error(
+ `A branch or tag with the name '${ref}' could not be found`
+ )
+ }
+ }
+
+ return result
+}
+
+export function getRefSpec(ref: string, commit: string): string[] {
+ if (!ref && !commit) {
+ throw new Error('Args ref and commit cannot both be empty')
+ }
+
+ const upperRef = (ref || '').toUpperCase()
+
+ // SHA
+ if (commit) {
+ // refs/heads
+ if (upperRef.startsWith('REFS/HEADS/')) {
+ const branch = ref.substring('refs/heads/'.length)
+ return [`+${commit}:refs/remotes/origin/${branch}`]
+ }
+ // refs/pull/
+ else if (upperRef.startsWith('REFS/PULL/')) {
+ const branch = ref.substring('refs/pull/'.length)
+ return [`+${commit}:refs/remotes/pull/${branch}`]
+ }
+ // refs/tags/
+ else if (upperRef.startsWith('REFS/TAGS/')) {
+ return [`+${commit}:${ref}`]
+ }
+ // Otherwise no destination ref
+ else {
+ return [commit]
+ }
+ }
+ // Unqualified ref, check for a matching branch or tag
+ else if (!upperRef.startsWith('REFS/')) {
+ return [
+ `+refs/heads/${ref}*:refs/remotes/origin/${ref}*`,
+ `+refs/tags/${ref}*:refs/tags/${ref}*`
+ ]
+ }
+ // refs/heads/
+ else if (upperRef.startsWith('REFS/HEADS/')) {
+ const branch = ref.substring('refs/heads/'.length)
+ return [`+${ref}:refs/remotes/origin/${branch}`]
+ }
+ // refs/pull/
+ else if (upperRef.startsWith('REFS/PULL/')) {
+ const branch = ref.substring('refs/pull/'.length)
+ return [`+${ref}:refs/remotes/pull/${branch}`]
+ }
+ // refs/tags/
+ else {
+ return [`+${ref}:${ref}`]
+ }
+}