From 422dc4567157f4d62b665a8a288310365b1d194b Mon Sep 17 00:00:00 2001 From: eric sciple Date: Thu, 5 Mar 2020 14:21:59 -0500 Subject: [PATCH] add support for submodules (#173) --- .github/workflows/test.yml | 29 ++ README.md | 5 + __test__/git-auth-helper.test.ts | 296 ++++++++++++++--- __test__/git-directory-helper.test.ts | 4 + __test__/input-helper.test.ts | 7 - __test__/verify-submodules-false.sh | 11 + __test__/verify-submodules-not-checked-out.sh | 11 - __test__/verify-submodules-recursive.sh | 26 ++ __test__/verify-submodules-true.sh | 26 ++ action.yml | 5 + dist/index.js | 309 ++++++++++++++---- src/git-auth-helper.ts | 154 +++++++-- src/git-command-manager.ts | 95 +++++- src/git-source-provider.ts | 128 +++++--- src/git-source-settings.ts | 2 + src/input-helper.ts | 20 +- src/regexp-helper.ts | 5 + 17 files changed, 914 insertions(+), 219 deletions(-) create mode 100755 __test__/verify-submodules-false.sh delete mode 100755 __test__/verify-submodules-not-checked-out.sh create mode 100755 __test__/verify-submodules-recursive.sh create mode 100755 __test__/verify-submodules-true.sh create mode 100644 src/regexp-helper.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8c0e759..3121c41 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -84,6 +84,35 @@ jobs: shell: bash run: __test__/verify-lfs.sh + # Submodules false + - name: Submodules false checkout + uses: ./ + with: + ref: test-data/v2/submodule + path: submodules-false + - name: Verify submodules false + run: __test__/verify-submodules-false.sh + + # Submodules one level + - name: Submodules true checkout + uses: ./ + with: + ref: test-data/v2/submodule + path: submodules-true + submodules: true + - name: Verify submodules true + run: __test__/verify-submodules-true.sh + + # Submodules recursive + - name: Submodules recursive checkout + uses: ./ + with: + ref: test-data/v2/submodule + path: submodules-recursive + submodules: recursive + - name: Verify submodules recursive + run: __test__/verify-submodules-recursive.sh + # Basic checkout using REST API - name: Remove basic if: runner.os != 'windows' diff --git a/README.md b/README.md index 54b3429..9233a25 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,11 @@ Refer [here](https://github.com/actions/checkout/blob/v1/README.md) for previous # Whether to download Git-LFS files # Default: false lfs: '' + + # Whether to checkout submodules: `true` to checkout submodules or `recursive` to + # recursively checkout submodules. + # Default: false + submodules: '' ``` diff --git a/__test__/git-auth-helper.test.ts b/__test__/git-auth-helper.test.ts index fab9f37..68926f2 100644 --- a/__test__/git-auth-helper.test.ts +++ b/__test__/git-auth-helper.test.ts @@ -8,10 +8,13 @@ import {IGitSourceSettings} from '../lib/git-source-settings' const testWorkspace = path.join(__dirname, '_temp', 'git-auth-helper') const originalRunnerTemp = process.env['RUNNER_TEMP'] +const originalHome = process.env['HOME'] let workspace: string -let gitConfigPath: string +let localGitConfigPath: string +let globalGitConfigPath: string let runnerTemp: string -let git: IGitCommandManager +let tempHomedir: string +let git: IGitCommandManager & {env: {[key: string]: string}} let settings: IGitSourceSettings describe('git-auth-helper tests', () => { @@ -23,11 +26,24 @@ describe('git-auth-helper tests', () => { beforeEach(() => { // Mock setSecret jest.spyOn(core, 'setSecret').mockImplementation((secret: string) => {}) + + // Mock error/warning/info/debug + jest.spyOn(core, 'error').mockImplementation(jest.fn()) + jest.spyOn(core, 'warning').mockImplementation(jest.fn()) + jest.spyOn(core, 'info').mockImplementation(jest.fn()) + jest.spyOn(core, 'debug').mockImplementation(jest.fn()) }) afterEach(() => { // Unregister mocks jest.restoreAllMocks() + + // Restore HOME + if (originalHome) { + process.env['HOME'] = originalHome + } else { + delete process.env['HOME'] + } }) afterAll(() => { @@ -38,10 +54,11 @@ describe('git-auth-helper tests', () => { } }) - const configuresAuthHeader = 'configures auth header' - it(configuresAuthHeader, async () => { + const configureAuth_configuresAuthHeader = + 'configureAuth configures auth header' + it(configureAuth_configuresAuthHeader, async () => { // Arrange - await setup(configuresAuthHeader) + await setup(configureAuth_configuresAuthHeader) expect(settings.authToken).toBeTruthy() // sanity check const authHelper = gitAuthHelper.createAuthHelper(git, settings) @@ -49,7 +66,9 @@ describe('git-auth-helper tests', () => { await authHelper.configureAuth() // Assert config - const configContent = (await fs.promises.readFile(gitConfigPath)).toString() + const configContent = ( + await fs.promises.readFile(localGitConfigPath) + ).toString() const basicCredential = Buffer.from( `x-access-token:${settings.authToken}`, 'utf8' @@ -61,32 +80,39 @@ describe('git-auth-helper tests', () => { ).toBeGreaterThanOrEqual(0) }) - const configuresAuthHeaderEvenWhenPersistCredentialsFalse = - 'configures auth header even when persist credentials false' - it(configuresAuthHeaderEvenWhenPersistCredentialsFalse, async () => { - // Arrange - await setup(configuresAuthHeaderEvenWhenPersistCredentialsFalse) - expect(settings.authToken).toBeTruthy() // sanity check - settings.persistCredentials = false - const authHelper = gitAuthHelper.createAuthHelper(git, settings) + const configureAuth_configuresAuthHeaderEvenWhenPersistCredentialsFalse = + 'configureAuth configures auth header even when persist credentials false' + it( + configureAuth_configuresAuthHeaderEvenWhenPersistCredentialsFalse, + async () => { + // Arrange + await setup( + configureAuth_configuresAuthHeaderEvenWhenPersistCredentialsFalse + ) + expect(settings.authToken).toBeTruthy() // sanity check + settings.persistCredentials = false + const authHelper = gitAuthHelper.createAuthHelper(git, settings) - // Act - await authHelper.configureAuth() + // Act + await authHelper.configureAuth() - // Assert config - const configContent = (await fs.promises.readFile(gitConfigPath)).toString() - expect( - configContent.indexOf( - `http.https://github.com/.extraheader AUTHORIZATION` - ) - ).toBeGreaterThanOrEqual(0) - }) + // Assert config + const configContent = ( + await fs.promises.readFile(localGitConfigPath) + ).toString() + expect( + configContent.indexOf( + `http.https://github.com/.extraheader AUTHORIZATION` + ) + ).toBeGreaterThanOrEqual(0) + } + ) - const registersBasicCredentialAsSecret = - 'registers basic credential as secret' - it(registersBasicCredentialAsSecret, async () => { + const configureAuth_registersBasicCredentialAsSecret = + 'configureAuth registers basic credential as secret' + it(configureAuth_registersBasicCredentialAsSecret, async () => { // Arrange - await setup(registersBasicCredentialAsSecret) + await setup(configureAuth_registersBasicCredentialAsSecret) expect(settings.authToken).toBeTruthy() // sanity check const authHelper = gitAuthHelper.createAuthHelper(git, settings) @@ -103,14 +129,139 @@ describe('git-auth-helper tests', () => { expect(setSecretSpy).toHaveBeenCalledWith(expectedSecret) }) - const removesToken = 'removes token' - it(removesToken, async () => { + const configureGlobalAuth_copiesGlobalGitConfig = + 'configureGlobalAuth copies global git config' + it(configureGlobalAuth_copiesGlobalGitConfig, async () => { + // Arrange + await setup(configureGlobalAuth_copiesGlobalGitConfig) + await fs.promises.writeFile(globalGitConfigPath, 'value-from-global-config') + const authHelper = gitAuthHelper.createAuthHelper(git, settings) + + // Act + await authHelper.configureAuth() + await authHelper.configureGlobalAuth() + + // Assert original global config not altered + let configContent = ( + await fs.promises.readFile(globalGitConfigPath) + ).toString() + expect(configContent).toBe('value-from-global-config') + + // Assert temporary global config + expect(git.env['HOME']).toBeTruthy() + const basicCredential = Buffer.from( + `x-access-token:${settings.authToken}`, + 'utf8' + ).toString('base64') + configContent = ( + await fs.promises.readFile(path.join(git.env['HOME'], '.gitconfig')) + ).toString() + expect( + configContent.indexOf('value-from-global-config') + ).toBeGreaterThanOrEqual(0) + expect( + configContent.indexOf( + `http.https://github.com/.extraheader AUTHORIZATION: basic ${basicCredential}` + ) + ).toBeGreaterThanOrEqual(0) + }) + + const configureGlobalAuth_createsNewGlobalGitConfigWhenGlobalDoesNotExist = + 'configureGlobalAuth creates new git config when global does not exist' + it( + configureGlobalAuth_createsNewGlobalGitConfigWhenGlobalDoesNotExist, + async () => { + // Arrange + await setup( + configureGlobalAuth_createsNewGlobalGitConfigWhenGlobalDoesNotExist + ) + await io.rmRF(globalGitConfigPath) + const authHelper = gitAuthHelper.createAuthHelper(git, settings) + + // Act + await authHelper.configureAuth() + await authHelper.configureGlobalAuth() + + // Assert original global config not recreated + try { + await fs.promises.stat(globalGitConfigPath) + throw new Error( + `Did not expect file to exist: '${globalGitConfigPath}'` + ) + } catch (err) { + if (err.code !== 'ENOENT') { + throw err + } + } + + // Assert temporary global config + expect(git.env['HOME']).toBeTruthy() + const basicCredential = Buffer.from( + `x-access-token:${settings.authToken}`, + 'utf8' + ).toString('base64') + const configContent = ( + await fs.promises.readFile(path.join(git.env['HOME'], '.gitconfig')) + ).toString() + expect( + configContent.indexOf( + `http.https://github.com/.extraheader AUTHORIZATION: basic ${basicCredential}` + ) + ).toBeGreaterThanOrEqual(0) + } + ) + + const configureSubmoduleAuth_doesNotConfigureTokenWhenPersistCredentialsFalse = + 'configureSubmoduleAuth does not configure token when persist credentials false' + it( + configureSubmoduleAuth_doesNotConfigureTokenWhenPersistCredentialsFalse, + async () => { + // Arrange + await setup( + configureSubmoduleAuth_doesNotConfigureTokenWhenPersistCredentialsFalse + ) + settings.persistCredentials = false + const authHelper = gitAuthHelper.createAuthHelper(git, settings) + await authHelper.configureAuth() + ;(git.submoduleForeach as jest.Mock).mockClear() // reset calls + + // Act + await authHelper.configureSubmoduleAuth() + + // Assert + expect(git.submoduleForeach).not.toHaveBeenCalled() + } + ) + + const configureSubmoduleAuth_configuresTokenWhenPersistCredentialsTrue = + 'configureSubmoduleAuth configures token when persist credentials true' + it( + configureSubmoduleAuth_configuresTokenWhenPersistCredentialsTrue, + async () => { + // Arrange + await setup( + configureSubmoduleAuth_configuresTokenWhenPersistCredentialsTrue + ) + const authHelper = gitAuthHelper.createAuthHelper(git, settings) + await authHelper.configureAuth() + ;(git.submoduleForeach as jest.Mock).mockClear() // reset calls + + // Act + await authHelper.configureSubmoduleAuth() + + // Assert + expect(git.submoduleForeach).toHaveBeenCalledTimes(1) + } + ) + + const removeAuth_removesToken = 'removeAuth removes token' + it(removeAuth_removesToken, async () => { // Arrange - await setup(removesToken) + await setup(removeAuth_removesToken) const authHelper = gitAuthHelper.createAuthHelper(git, settings) await authHelper.configureAuth() let gitConfigContent = ( - await fs.promises.readFile(gitConfigPath) + await fs.promises.readFile(localGitConfigPath) ).toString() expect(gitConfigContent.indexOf('http.')).toBeGreaterThanOrEqual(0) // sanity check @@ -118,9 +269,37 @@ describe('git-auth-helper tests', () => { await authHelper.removeAuth() // Assert git config - gitConfigContent = (await fs.promises.readFile(gitConfigPath)).toString() + gitConfigContent = ( + await fs.promises.readFile(localGitConfigPath) + ).toString() expect(gitConfigContent.indexOf('http.')).toBeLessThan(0) }) + + const removeGlobalAuth_removesOverride = 'removeGlobalAuth removes override' + it(removeGlobalAuth_removesOverride, async () => { + // Arrange + await setup(removeGlobalAuth_removesOverride) + const authHelper = gitAuthHelper.createAuthHelper(git, settings) + await authHelper.configureAuth() + await authHelper.configureGlobalAuth() + const homeOverride = git.env['HOME'] // Sanity check + expect(homeOverride).toBeTruthy() + await fs.promises.stat(path.join(git.env['HOME'], '.gitconfig')) + + // Act + await authHelper.removeGlobalAuth() + + // Assert + expect(git.env['HOME']).toBeUndefined() + try { + await fs.promises.stat(homeOverride) + throw new Error(`Should have been deleted '${homeOverride}'`) + } catch (err) { + if (err.code !== 'ENOENT') { + throw err + } + } + }) }) async function setup(testName: string): Promise { @@ -129,14 +308,19 @@ async function setup(testName: string): Promise { // Directories workspace = path.join(testWorkspace, testName, 'workspace') runnerTemp = path.join(testWorkspace, testName, 'runner-temp') + tempHomedir = path.join(testWorkspace, testName, 'home-dir') await fs.promises.mkdir(workspace, {recursive: true}) await fs.promises.mkdir(runnerTemp, {recursive: true}) + await fs.promises.mkdir(tempHomedir, {recursive: true}) process.env['RUNNER_TEMP'] = runnerTemp + process.env['HOME'] = tempHomedir // Create git config - gitConfigPath = path.join(workspace, '.git', 'config') - await fs.promises.mkdir(path.join(workspace, '.git'), {recursive: true}) - await fs.promises.writeFile(path.join(workspace, '.git', 'config'), '') + globalGitConfigPath = path.join(tempHomedir, '.gitconfig') + await fs.promises.writeFile(globalGitConfigPath, '') + localGitConfigPath = path.join(workspace, '.git', 'config') + await fs.promises.mkdir(path.dirname(localGitConfigPath), {recursive: true}) + await fs.promises.writeFile(localGitConfigPath, '') git = { branchDelete: jest.fn(), @@ -144,12 +328,20 @@ async function setup(testName: string): Promise { branchList: jest.fn(), checkout: jest.fn(), checkoutDetach: jest.fn(), - config: jest.fn(async (key: string, value: string) => { - await fs.promises.appendFile(gitConfigPath, `\n${key} ${value}`) - }), + config: jest.fn( + async (key: string, value: string, globalConfig?: boolean) => { + const configPath = globalConfig + ? path.join(git.env['HOME'] || tempHomedir, '.gitconfig') + : localGitConfigPath + await fs.promises.appendFile(configPath, `\n${key} ${value}`) + } + ), configExists: jest.fn( - async (key: string): Promise => { - const content = await fs.promises.readFile(gitConfigPath) + async (key: string, globalConfig?: boolean): Promise => { + const configPath = globalConfig + ? path.join(git.env['HOME'] || tempHomedir, '.gitconfig') + : localGitConfigPath + const content = await fs.promises.readFile(configPath) const lines = content .toString() .split('\n') @@ -157,6 +349,7 @@ async function setup(testName: string): Promise { return lines.some(x => x.startsWith(key)) } ), + env: {}, fetch: jest.fn(), getWorkingDirectory: jest.fn(() => workspace), init: jest.fn(), @@ -165,18 +358,29 @@ async function setup(testName: string): Promise { lfsInstall: jest.fn(), log1: jest.fn(), remoteAdd: jest.fn(), - setEnvironmentVariable: jest.fn(), + removeEnvironmentVariable: jest.fn((name: string) => delete git.env[name]), + setEnvironmentVariable: jest.fn((name: string, value: string) => { + git.env[name] = value + }), + submoduleForeach: jest.fn(async () => { + return '' + }), + submoduleSync: jest.fn(), + submoduleUpdate: jest.fn(), tagExists: jest.fn(), tryClean: jest.fn(), tryConfigUnset: jest.fn( - async (key: string): Promise => { - let content = await fs.promises.readFile(gitConfigPath) + async (key: string, globalConfig?: boolean): Promise => { + const configPath = globalConfig + ? path.join(git.env['HOME'] || tempHomedir, '.gitconfig') + : localGitConfigPath + let content = await fs.promises.readFile(configPath) let lines = content .toString() .split('\n') .filter(x => x) .filter(x => !x.startsWith(key)) - await fs.promises.writeFile(gitConfigPath, lines.join('\n')) + await fs.promises.writeFile(configPath, lines.join('\n')) return true } ), @@ -191,6 +395,8 @@ async function setup(testName: string): Promise { commit: '', fetchDepth: 1, lfs: false, + submodules: false, + nestedSubmodules: false, persistCredentials: true, ref: 'refs/heads/master', repositoryName: 'my-repo', diff --git a/__test__/git-directory-helper.test.ts b/__test__/git-directory-helper.test.ts index 4383e1d..c39a2a5 100644 --- a/__test__/git-directory-helper.test.ts +++ b/__test__/git-directory-helper.test.ts @@ -363,7 +363,11 @@ async function setup(testName: string): Promise { lfsInstall: jest.fn(), log1: jest.fn(), remoteAdd: jest.fn(), + removeEnvironmentVariable: jest.fn(), setEnvironmentVariable: jest.fn(), + submoduleForeach: jest.fn(), + submoduleSync: jest.fn(), + submoduleUpdate: jest.fn(), tagExists: jest.fn(), tryClean: jest.fn(async () => { return true diff --git a/__test__/input-helper.test.ts b/__test__/input-helper.test.ts index 53c7000..00732ef 100644 --- a/__test__/input-helper.test.ts +++ b/__test__/input-helper.test.ts @@ -130,11 +130,4 @@ describe('input-helper tests', () => { expect(settings.ref).toBe('refs/heads/some-other-ref') expect(settings.commit).toBeFalsy() }) - - it('gives good error message for submodules input', () => { - inputs.submodules = 'true' - assert.throws(() => { - inputHelper.getInputs() - }, /The input 'submodules' is not supported/) - }) }) diff --git a/__test__/verify-submodules-false.sh b/__test__/verify-submodules-false.sh new file mode 100755 index 0000000..733e247 --- /dev/null +++ b/__test__/verify-submodules-false.sh @@ -0,0 +1,11 @@ +#!/bin/bash + +if [ ! -f "./submodules-false/regular-file.txt" ]; then + echo "Expected regular file does not exist" + exit 1 +fi + +if [ -f "./submodules-false/submodule-level-1/submodule-file.txt" ]; then + echo "Unexpected submodule file exists" + exit 1 +fi \ No newline at end of file diff --git a/__test__/verify-submodules-not-checked-out.sh b/__test__/verify-submodules-not-checked-out.sh deleted file mode 100755 index dcc0487..0000000 --- a/__test__/verify-submodules-not-checked-out.sh +++ /dev/null @@ -1,11 +0,0 @@ -#!/bin/bash - -if [ ! -f "./submodules-not-checked-out/regular-file.txt" ]; then - echo "Expected regular file does not exist" - exit 1 -fi - -if [ -f "./submodules-not-checked-out/submodule-level-1/submodule-file.txt" ]; then - echo "Unexpected submodule file exists" - exit 1 -fi diff --git a/__test__/verify-submodules-recursive.sh b/__test__/verify-submodules-recursive.sh new file mode 100755 index 0000000..1b68f9b --- /dev/null +++ b/__test__/verify-submodules-recursive.sh @@ -0,0 +1,26 @@ +#!/bin/bash + +if [ ! -f "./submodules-recursive/regular-file.txt" ]; then + echo "Expected regular file does not exist" + exit 1 +fi + +if [ ! -f "./submodules-recursive/submodule-level-1/submodule-file.txt" ]; then + echo "Expected submodule file does not exist" + exit 1 +fi + +if [ ! -f "./submodules-recursive/submodule-level-1/submodule-level-2/nested-submodule-file.txt" ]; then + echo "Expected nested submodule file does not exists" + exit 1 +fi + +echo "Testing persisted credential" +pushd ./submodules-recursive/submodule-level-1/submodule-level-2 +git config --local --name-only --get-regexp http.+extraheader && git fetch +if [ "$?" != "0" ]; then + echo "Failed to validate persisted credential" + popd + exit 1 +fi +popd diff --git a/__test__/verify-submodules-true.sh b/__test__/verify-submodules-true.sh new file mode 100755 index 0000000..43769fe --- /dev/null +++ b/__test__/verify-submodules-true.sh @@ -0,0 +1,26 @@ +#!/bin/bash + +if [ ! -f "./submodules-true/regular-file.txt" ]; then + echo "Expected regular file does not exist" + exit 1 +fi + +if [ ! -f "./submodules-true/submodule-level-1/submodule-file.txt" ]; then + echo "Expected submodule file does not exist" + exit 1 +fi + +if [ -f "./submodules-true/submodule-level-1/submodule-level-2/nested-submodule-file.txt" ]; then + echo "Unexpected nested submodule file exists" + exit 1 +fi + +echo "Testing persisted credential" +pushd ./submodules-true/submodule-level-1 +git config --local --name-only --get-regexp http.+extraheader && git fetch +if [ "$?" != "0" ]; then + echo "Failed to validate persisted credential" + popd + exit 1 +fi +popd diff --git a/action.yml b/action.yml index 9650409..a411037 100644 --- a/action.yml +++ b/action.yml @@ -30,6 +30,11 @@ inputs: lfs: description: 'Whether to download Git-LFS files' default: false + submodules: + description: > + Whether to checkout submodules: `true` to checkout submodules or `recursive` to + recursively checkout submodules. + default: false runs: using: node12 main: dist/index.js diff --git a/dist/index.js b/dist/index.js index 4b06982..3a29067 100644 --- a/dist/index.js +++ b/dist/index.js @@ -5074,21 +5074,35 @@ var __importStar = (this && this.__importStar) || function (mod) { result["default"] = mod; return result; }; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; Object.defineProperty(exports, "__esModule", { value: true }); +const assert = __importStar(__webpack_require__(357)); const core = __importStar(__webpack_require__(470)); const fs = __importStar(__webpack_require__(747)); +const io = __importStar(__webpack_require__(1)); +const os = __importStar(__webpack_require__(87)); const path = __importStar(__webpack_require__(622)); +const regexpHelper = __importStar(__webpack_require__(528)); +const v4_1 = __importDefault(__webpack_require__(826)); const IS_WINDOWS = process.platform === 'win32'; const HOSTNAME = 'github.com'; -const EXTRA_HEADER_KEY = `http.https://${HOSTNAME}/.extraheader`; function createAuthHelper(git, settings) { return new GitAuthHelper(git, settings); } exports.createAuthHelper = createAuthHelper; class GitAuthHelper { constructor(gitCommandManager, gitSourceSettings) { + this.tokenConfigKey = `http.https://${HOSTNAME}/.extraheader`; + this.temporaryHomePath = ''; this.git = gitCommandManager; this.settings = gitSourceSettings || {}; + // Token auth header + const basicCredential = Buffer.from(`x-access-token:${this.settings.authToken}`, 'utf8').toString('base64'); + core.setSecret(basicCredential); + this.tokenPlaceholderConfigValue = `AUTHORIZATION: basic ***`; + this.tokenConfigValue = `AUTHORIZATION: basic ${basicCredential}`; } configureAuth() { return __awaiter(this, void 0, void 0, function* () { @@ -5098,37 +5112,110 @@ class GitAuthHelper { yield this.configureToken(); }); } + configureGlobalAuth() { + return __awaiter(this, void 0, void 0, function* () { + // Create a temp home directory + const runnerTemp = process.env['RUNNER_TEMP'] || ''; + assert.ok(runnerTemp, 'RUNNER_TEMP is not defined'); + const uniqueId = v4_1.default(); + this.temporaryHomePath = path.join(runnerTemp, uniqueId); + yield fs.promises.mkdir(this.temporaryHomePath, { recursive: true }); + // Copy the global git config + const gitConfigPath = path.join(process.env['HOME'] || os.homedir(), '.gitconfig'); + const newGitConfigPath = path.join(this.temporaryHomePath, '.gitconfig'); + let configExists = false; + try { + yield fs.promises.stat(gitConfigPath); + configExists = true; + } + catch (err) { + if (err.code !== 'ENOENT') { + throw err; + } + } + if (configExists) { + core.info(`Copying '${gitConfigPath}' to '${newGitConfigPath}'`); + yield io.cp(gitConfigPath, newGitConfigPath); + } + else { + yield fs.promises.writeFile(newGitConfigPath, ''); + } + // Configure the token + try { + core.info(`Temporarily overriding HOME='${this.temporaryHomePath}' before making global git config changes`); + this.git.setEnvironmentVariable('HOME', this.temporaryHomePath); + yield this.configureToken(newGitConfigPath, true); + } + catch (err) { + // Unset in case somehow written to the real global config + core.info('Encountered an error when attempting to configure token. Attempting unconfigure.'); + yield this.git.tryConfigUnset(this.tokenConfigKey, true); + throw err; + } + }); + } + configureSubmoduleAuth() { + return __awaiter(this, void 0, void 0, function* () { + if (this.settings.persistCredentials) { + // Configure a placeholder value. This approach avoids the credential being captured + // by process creation audit events, which are commonly logged. For more information, + // refer to https://docs.microsoft.com/en-us/windows-server/identity/ad-ds/manage/component-updates/command-line-process-auditing + const output = yield this.git.submoduleForeach(`git config "${this.tokenConfigKey}" "${this.tokenPlaceholderConfigValue}" && git config --local --show-origin --name-only --get-regexp remote.origin.url`, this.settings.nestedSubmodules); + // Replace the placeholder + const configPaths = output.match(/(?<=(^|\n)file:)[^\t]+(?=\tremote\.origin\.url)/g) || []; + for (const configPath of configPaths) { + core.debug(`Replacing token placeholder in '${configPath}'`); + this.replaceTokenPlaceholder(configPath); + } + } + }); + } removeAuth() { return __awaiter(this, void 0, void 0, function* () { yield this.removeToken(); }); } - configureToken() { + removeGlobalAuth() { + return __awaiter(this, void 0, void 0, function* () { + core.info(`Unsetting HOME override`); + this.git.removeEnvironmentVariable('HOME'); + yield io.rmRF(this.temporaryHomePath); + }); + } + configureToken(configPath, globalConfig) { return __awaiter(this, void 0, void 0, function* () { + // Validate args + assert.ok((configPath && globalConfig) || (!configPath && !globalConfig), 'Unexpected configureToken parameter combinations'); + // Default config path + if (!configPath && !globalConfig) { + configPath = path.join(this.git.getWorkingDirectory(), '.git', 'config'); + } // Configure a placeholder value. This approach avoids the credential being captured // by process creation audit events, which are commonly logged. For more information, // refer to https://docs.microsoft.com/en-us/windows-server/identity/ad-ds/manage/component-updates/command-line-process-auditing - const placeholder = `AUTHORIZATION: basic ***`; - yield this.git.config(EXTRA_HEADER_KEY, placeholder); - // Determine the basic credential value - const basicCredential = Buffer.from(`x-access-token:${this.settings.authToken}`, 'utf8').toString('base64'); - core.setSecret(basicCredential); - // Replace the value in the config file - const configPath = path.join(this.git.getWorkingDirectory(), '.git', 'config'); + yield this.git.config(this.tokenConfigKey, this.tokenPlaceholderConfigValue, globalConfig); + // Replace the placeholder + yield this.replaceTokenPlaceholder(configPath || ''); + }); + } + replaceTokenPlaceholder(configPath) { + return __awaiter(this, void 0, void 0, function* () { + assert.ok(configPath, 'configPath is not defined'); let content = (yield fs.promises.readFile(configPath)).toString(); - const placeholderIndex = content.indexOf(placeholder); + const placeholderIndex = content.indexOf(this.tokenPlaceholderConfigValue); if (placeholderIndex < 0 || - placeholderIndex != content.lastIndexOf(placeholder)) { - throw new Error('Unable to replace auth placeholder in .git/config'); + placeholderIndex != content.lastIndexOf(this.tokenPlaceholderConfigValue)) { + throw new Error(`Unable to replace auth placeholder in ${configPath}`); } - content = content.replace(placeholder, `AUTHORIZATION: basic ${basicCredential}`); + assert.ok(this.tokenConfigValue, 'tokenConfigValue is not defined'); + content = content.replace(this.tokenPlaceholderConfigValue, this.tokenConfigValue); yield fs.promises.writeFile(configPath, content); }); } removeToken() { return __awaiter(this, void 0, void 0, function* () { // HTTP extra header - yield this.removeGitConfig(EXTRA_HEADER_KEY); + yield this.removeGitConfig(this.tokenConfigKey); }); } removeGitConfig(configKey) { @@ -5138,6 +5225,8 @@ class GitAuthHelper { // Load the config contents core.warning(`Failed to remove '${configKey}' from the git config`); } + const pattern = regexpHelper.escape(configKey); + yield this.git.submoduleForeach(`git config --local --name-only --get-regexp ${pattern} && git config --local --unset-all ${configKey} || :`, true); }); } } @@ -5172,6 +5261,7 @@ const exec = __importStar(__webpack_require__(986)); const fshelper = __importStar(__webpack_require__(618)); const io = __importStar(__webpack_require__(1)); const path = __importStar(__webpack_require__(622)); +const regexpHelper = __importStar(__webpack_require__(528)); const retryHelper = __importStar(__webpack_require__(587)); const git_version_1 = __webpack_require__(559); // Auth header not supported before 2.9 @@ -5263,17 +5353,26 @@ class GitCommandManager { yield this.execGit(args); }); } - config(configKey, configValue) { + config(configKey, configValue, globalConfig) { return __awaiter(this, void 0, void 0, function* () { - yield this.execGit(['config', '--local', configKey, configValue]); + yield this.execGit([ + 'config', + globalConfig ? '--global' : '--local', + configKey, + configValue + ]); }); } - configExists(configKey) { + configExists(configKey, globalConfig) { return __awaiter(this, void 0, void 0, function* () { - const pattern = configKey.replace(/[^a-zA-Z0-9_]/g, x => { - return `\\${x}`; - }); - const output = yield this.execGit(['config', '--local', '--name-only', '--get-regexp', pattern], true); + const pattern = regexpHelper.escape(configKey); + const output = yield this.execGit([ + 'config', + globalConfig ? '--global' : '--local', + '--name-only', + '--get-regexp', + pattern + ], true); return output.exitCode === 0; }); } @@ -5343,9 +5442,45 @@ class GitCommandManager { yield this.execGit(['remote', 'add', remoteName, remoteUrl]); }); } + removeEnvironmentVariable(name) { + delete this.gitEnv[name]; + } setEnvironmentVariable(name, value) { this.gitEnv[name] = value; } + submoduleForeach(command, recursive) { + return __awaiter(this, void 0, void 0, function* () { + const args = ['submodule', 'foreach']; + if (recursive) { + args.push('--recursive'); + } + args.push(command); + const output = yield this.execGit(args); + return output.stdout; + }); + } + submoduleSync(recursive) { + return __awaiter(this, void 0, void 0, function* () { + const args = ['submodule', 'sync']; + if (recursive) { + args.push('--recursive'); + } + yield this.execGit(args); + }); + } + submoduleUpdate(fetchDepth, recursive) { + return __awaiter(this, void 0, void 0, function* () { + const args = ['-c', 'protocol.version=2']; + args.push('submodule', 'update', '--init', '--force'); + if (fetchDepth > 0) { + args.push(`--depth=${fetchDepth}`); + } + if (recursive) { + args.push('--recursive'); + } + yield this.execGit(args); + }); + } tagExists(pattern) { return __awaiter(this, void 0, void 0, function* () { const output = yield this.execGit(['tag', '--list', pattern]); @@ -5358,9 +5493,14 @@ class GitCommandManager { return output.exitCode === 0; }); } - tryConfigUnset(configKey) { + tryConfigUnset(configKey, globalConfig) { return __awaiter(this, void 0, void 0, function* () { - const output = yield this.execGit(['config', '--local', '--unset-all', configKey], true); + const output = yield this.execGit([ + 'config', + globalConfig ? '--global' : '--local', + '--unset-all', + configKey + ], true); return output.exitCode === 0; }); } @@ -5551,48 +5691,66 @@ function getSource(settings) { core.info(`The repository will be downloaded using the GitHub REST API`); core.info(`To create a local Git repository instead, add Git ${gitCommandManager.MinimumGitVersion} or higher to the PATH`); yield githubApiHelper.downloadRepository(settings.authToken, settings.repositoryOwner, settings.repositoryName, settings.ref, settings.commit, settings.repositoryPath); + return; } - else { - // Save state for POST action - stateHelper.setRepositoryPath(settings.repositoryPath); - // Initialize the repository - if (!fsHelper.directoryExistsSync(path.join(settings.repositoryPath, '.git'))) { - yield git.init(); - yield git.remoteAdd('origin', repositoryUrl); + // Save state for POST action + stateHelper.setRepositoryPath(settings.repositoryPath); + // Initialize the repository + if (!fsHelper.directoryExistsSync(path.join(settings.repositoryPath, '.git'))) { + yield git.init(); + yield git.remoteAdd('origin', repositoryUrl); + } + // Disable automatic garbage collection + if (!(yield git.tryDisableAutomaticGarbageCollection())) { + core.warning(`Unable to turn off git automatic garbage collection. The git fetch operation may trigger garbage collection and cause a delay.`); + } + const authHelper = gitAuthHelper.createAuthHelper(git, settings); + try { + // Configure auth + yield authHelper.configureAuth(); + // LFS install + if (settings.lfs) { + yield git.lfsInstall(); } - // Disable automatic garbage collection - if (!(yield git.tryDisableAutomaticGarbageCollection())) { - core.warning(`Unable to turn off git automatic garbage collection. The git fetch operation may trigger garbage collection and cause a delay.`); + // Fetch + const refSpec = refHelper.getRefSpec(settings.ref, settings.commit); + yield git.fetch(settings.fetchDepth, refSpec); + // Checkout info + const checkoutInfo = yield 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) { + yield git.lfsFetch(checkoutInfo.startPoint || checkoutInfo.ref); } - const authHelper = gitAuthHelper.createAuthHelper(git, settings); - try { - // Configure auth - yield authHelper.configureAuth(); - // LFS install - if (settings.lfs) { - yield git.lfsInstall(); + // Checkout + yield git.checkout(checkoutInfo.ref, checkoutInfo.startPoint); + // Submodules + if (settings.submodules) { + try { + // Temporarily override global config + yield authHelper.configureGlobalAuth(); + // Checkout submodules + yield git.submoduleSync(settings.nestedSubmodules); + yield git.submoduleUpdate(settings.fetchDepth, settings.nestedSubmodules); + yield git.submoduleForeach('git config --local gc.auto 0', settings.nestedSubmodules); + // Persist credentials + if (settings.persistCredentials) { + yield authHelper.configureSubmoduleAuth(); + } } - // Fetch - const refSpec = refHelper.getRefSpec(settings.ref, settings.commit); - yield git.fetch(settings.fetchDepth, refSpec); - // Checkout info - const checkoutInfo = yield 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) { - yield git.lfsFetch(checkoutInfo.startPoint || checkoutInfo.ref); + finally { + // Remove temporary global config override + yield authHelper.removeGlobalAuth(); } - // Checkout - yield git.checkout(checkoutInfo.ref, checkoutInfo.startPoint); - // Dump some info about the checked out commit - yield git.log1(); } - finally { - // Remove auth - if (!settings.persistCredentials) { - yield authHelper.removeAuth(); - } + // Dump some info about the checked out commit + yield git.log1(); + } + finally { + // Remove auth + if (!settings.persistCredentials) { + yield authHelper.removeAuth(); } } }); @@ -9428,6 +9586,22 @@ module.exports.Singular = Hook.Singular module.exports.Collection = Hook.Collection +/***/ }), + +/***/ 528: +/***/ (function(__unusedmodule, exports) { + +"use strict"; + +Object.defineProperty(exports, "__esModule", { value: true }); +function escape(value) { + return value.replace(/[^a-zA-Z0-9_]/g, x => { + return `\\${x}`; + }); +} +exports.escape = escape; + + /***/ }), /***/ 529: @@ -13731,10 +13905,6 @@ function getInputs() { // 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) { @@ -13744,6 +13914,19 @@ function getInputs() { // LFS result.lfs = (core.getInput('lfs') || 'false').toUpperCase() === 'TRUE'; core.debug(`lfs = ${result.lfs}`); + // Submodules + result.submodules = false; + result.nestedSubmodules = false; + const submodulesString = (core.getInput('submodules') || '').toUpperCase(); + if (submodulesString == 'RECURSIVE') { + result.submodules = true; + result.nestedSubmodules = true; + } + else if (submodulesString == 'TRUE') { + result.submodules = true; + } + core.debug(`submodules = ${result.submodules}`); + core.debug(`recursive submodules = ${result.nestedSubmodules}`); // Auth token result.authToken = core.getInput('token'); // Persist credentials diff --git a/src/git-auth-helper.ts b/src/git-auth-helper.ts index 7f7f11f..dd76fe9 100644 --- a/src/git-auth-helper.ts +++ b/src/git-auth-helper.ts @@ -5,6 +5,7 @@ import * as fs from 'fs' import * as io from '@actions/io' import * as os from 'os' import * as path from 'path' +import * as regexpHelper from './regexp-helper' import * as stateHelper from './state-helper' import {default as uuid} from 'uuid/v4' import {IGitCommandManager} from './git-command-manager' @@ -12,11 +13,13 @@ import {IGitSourceSettings} from './git-source-settings' const IS_WINDOWS = process.platform === 'win32' const HOSTNAME = 'github.com' -const EXTRA_HEADER_KEY = `http.https://${HOSTNAME}/.extraheader` export interface IGitAuthHelper { configureAuth(): Promise + configureGlobalAuth(): Promise + configureSubmoduleAuth(): Promise removeAuth(): Promise + removeGlobalAuth(): Promise } export function createAuthHelper( @@ -27,8 +30,12 @@ export function createAuthHelper( } class GitAuthHelper { - private git: IGitCommandManager - private settings: IGitSourceSettings + private readonly git: IGitCommandManager + private readonly settings: IGitSourceSettings + private readonly tokenConfigKey: string = `http.https://${HOSTNAME}/.extraheader` + private readonly tokenPlaceholderConfigValue: string + private temporaryHomePath = '' + private tokenConfigValue: string constructor( gitCommandManager: IGitCommandManager, @@ -36,6 +43,15 @@ class GitAuthHelper { ) { this.git = gitCommandManager this.settings = gitSourceSettings || (({} as unknown) as IGitSourceSettings) + + // Token auth header + const basicCredential = Buffer.from( + `x-access-token:${this.settings.authToken}`, + 'utf8' + ).toString('base64') + core.setSecret(basicCredential) + this.tokenPlaceholderConfigValue = `AUTHORIZATION: basic ***` + this.tokenConfigValue = `AUTHORIZATION: basic ${basicCredential}` } async configureAuth(): Promise { @@ -46,48 +62,132 @@ class GitAuthHelper { await this.configureToken() } + async configureGlobalAuth(): Promise { + // Create a temp home directory + const runnerTemp = process.env['RUNNER_TEMP'] || '' + assert.ok(runnerTemp, 'RUNNER_TEMP is not defined') + const uniqueId = uuid() + this.temporaryHomePath = path.join(runnerTemp, uniqueId) + await fs.promises.mkdir(this.temporaryHomePath, {recursive: true}) + + // Copy the global git config + const gitConfigPath = path.join( + process.env['HOME'] || os.homedir(), + '.gitconfig' + ) + const newGitConfigPath = path.join(this.temporaryHomePath, '.gitconfig') + let configExists = false + try { + await fs.promises.stat(gitConfigPath) + configExists = true + } catch (err) { + if (err.code !== 'ENOENT') { + throw err + } + } + if (configExists) { + core.info(`Copying '${gitConfigPath}' to '${newGitConfigPath}'`) + await io.cp(gitConfigPath, newGitConfigPath) + } else { + await fs.promises.writeFile(newGitConfigPath, '') + } + + // Configure the token + try { + core.info( + `Temporarily overriding HOME='${this.temporaryHomePath}' before making global git config changes` + ) + this.git.setEnvironmentVariable('HOME', this.temporaryHomePath) + await this.configureToken(newGitConfigPath, true) + } catch (err) { + // Unset in case somehow written to the real global config + core.info( + 'Encountered an error when attempting to configure token. Attempting unconfigure.' + ) + await this.git.tryConfigUnset(this.tokenConfigKey, true) + throw err + } + } + + async configureSubmoduleAuth(): Promise { + if (this.settings.persistCredentials) { + // Configure a placeholder value. This approach avoids the credential being captured + // by process creation audit events, which are commonly logged. For more information, + // refer to https://docs.microsoft.com/en-us/windows-server/identity/ad-ds/manage/component-updates/command-line-process-auditing + const output = await this.git.submoduleForeach( + `git config "${this.tokenConfigKey}" "${this.tokenPlaceholderConfigValue}" && git config --local --show-origin --name-only --get-regexp remote.origin.url`, + this.settings.nestedSubmodules + ) + + // Replace the placeholder + const configPaths: string[] = + output.match(/(?<=(^|\n)file:)[^\t]+(?=\tremote\.origin\.url)/g) || [] + for (const configPath of configPaths) { + core.debug(`Replacing token placeholder in '${configPath}'`) + this.replaceTokenPlaceholder(configPath) + } + } + } + async removeAuth(): Promise { await this.removeToken() } - private async configureToken(): Promise { + async removeGlobalAuth(): Promise { + core.info(`Unsetting HOME override`) + this.git.removeEnvironmentVariable('HOME') + await io.rmRF(this.temporaryHomePath) + } + + private async configureToken( + configPath?: string, + globalConfig?: boolean + ): Promise { + // Validate args + assert.ok( + (configPath && globalConfig) || (!configPath && !globalConfig), + 'Unexpected configureToken parameter combinations' + ) + + // Default config path + if (!configPath && !globalConfig) { + configPath = path.join(this.git.getWorkingDirectory(), '.git', 'config') + } + // Configure a placeholder value. This approach avoids the credential being captured // by process creation audit events, which are commonly logged. For more information, // refer to https://docs.microsoft.com/en-us/windows-server/identity/ad-ds/manage/component-updates/command-line-process-auditing - const placeholder = `AUTHORIZATION: basic ***` - await this.git.config(EXTRA_HEADER_KEY, placeholder) + await this.git.config( + this.tokenConfigKey, + this.tokenPlaceholderConfigValue, + globalConfig + ) - // Determine the basic credential value - const basicCredential = Buffer.from( - `x-access-token:${this.settings.authToken}`, - 'utf8' - ).toString('base64') - core.setSecret(basicCredential) + // Replace the placeholder + await this.replaceTokenPlaceholder(configPath || '') + } - // Replace the value in the config file - const configPath = path.join( - this.git.getWorkingDirectory(), - '.git', - 'config' - ) + private async replaceTokenPlaceholder(configPath: string): Promise { + assert.ok(configPath, 'configPath is not defined') let content = (await fs.promises.readFile(configPath)).toString() - const placeholderIndex = content.indexOf(placeholder) + const placeholderIndex = content.indexOf(this.tokenPlaceholderConfigValue) if ( placeholderIndex < 0 || - placeholderIndex != content.lastIndexOf(placeholder) + placeholderIndex != content.lastIndexOf(this.tokenPlaceholderConfigValue) ) { - throw new Error('Unable to replace auth placeholder in .git/config') + throw new Error(`Unable to replace auth placeholder in ${configPath}`) } + assert.ok(this.tokenConfigValue, 'tokenConfigValue is not defined') content = content.replace( - placeholder, - `AUTHORIZATION: basic ${basicCredential}` + this.tokenPlaceholderConfigValue, + this.tokenConfigValue ) await fs.promises.writeFile(configPath, content) } private async removeToken(): Promise { // HTTP extra header - await this.removeGitConfig(EXTRA_HEADER_KEY) + await this.removeGitConfig(this.tokenConfigKey) } private async removeGitConfig(configKey: string): Promise { @@ -98,5 +198,11 @@ class GitAuthHelper { // Load the config contents core.warning(`Failed to remove '${configKey}' from the git config`) } + + const pattern = regexpHelper.escape(configKey) + await this.git.submoduleForeach( + `git config --local --name-only --get-regexp ${pattern} && git config --local --unset-all ${configKey} || :`, + true + ) } } diff --git a/src/git-command-manager.ts b/src/git-command-manager.ts index 2b0d054..4cbfe4a 100644 --- a/src/git-command-manager.ts +++ b/src/git-command-manager.ts @@ -3,6 +3,7 @@ import * as exec from '@actions/exec' import * as fshelper from './fs-helper' import * as io from '@actions/io' import * as path from 'path' +import * as regexpHelper from './regexp-helper' import * as retryHelper from './retry-helper' import {GitVersion} from './git-version' @@ -16,8 +17,12 @@ export interface IGitCommandManager { branchList(remote: boolean): Promise checkout(ref: string, startPoint: string): Promise checkoutDetach(): Promise - config(configKey: string, configValue: string): Promise - configExists(configKey: string): Promise + config( + configKey: string, + configValue: string, + globalConfig?: boolean + ): Promise + configExists(configKey: string, globalConfig?: boolean): Promise fetch(fetchDepth: number, refSpec: string[]): Promise getWorkingDirectory(): string init(): Promise @@ -26,10 +31,14 @@ export interface IGitCommandManager { lfsInstall(): Promise log1(): Promise remoteAdd(remoteName: string, remoteUrl: string): Promise + removeEnvironmentVariable(name: string): void setEnvironmentVariable(name: string, value: string): void + submoduleForeach(command: string, recursive: boolean): Promise + submoduleSync(recursive: boolean): Promise + submoduleUpdate(fetchDepth: number, recursive: boolean): Promise tagExists(pattern: string): Promise tryClean(): Promise - tryConfigUnset(configKey: string): Promise + tryConfigUnset(configKey: string, globalConfig?: boolean): Promise tryDisableAutomaticGarbageCollection(): Promise tryGetFetchUrl(): Promise tryReset(): Promise @@ -124,16 +133,32 @@ class GitCommandManager { await this.execGit(args) } - async config(configKey: string, configValue: string): Promise { - await this.execGit(['config', '--local', configKey, configValue]) + async config( + configKey: string, + configValue: string, + globalConfig?: boolean + ): Promise { + await this.execGit([ + 'config', + globalConfig ? '--global' : '--local', + configKey, + configValue + ]) } - async configExists(configKey: string): Promise { - const pattern = configKey.replace(/[^a-zA-Z0-9_]/g, x => { - return `\\${x}` - }) + async configExists( + configKey: string, + globalConfig?: boolean + ): Promise { + const pattern = regexpHelper.escape(configKey) const output = await this.execGit( - ['config', '--local', '--name-only', '--get-regexp', pattern], + [ + 'config', + globalConfig ? '--global' : '--local', + '--name-only', + '--get-regexp', + pattern + ], true ) return output.exitCode === 0 @@ -208,10 +233,48 @@ class GitCommandManager { await this.execGit(['remote', 'add', remoteName, remoteUrl]) } + removeEnvironmentVariable(name: string): void { + delete this.gitEnv[name] + } + setEnvironmentVariable(name: string, value: string): void { this.gitEnv[name] = value } + async submoduleForeach(command: string, recursive: boolean): Promise { + const args = ['submodule', 'foreach'] + if (recursive) { + args.push('--recursive') + } + args.push(command) + + const output = await this.execGit(args) + return output.stdout + } + + async submoduleSync(recursive: boolean): Promise { + const args = ['submodule', 'sync'] + if (recursive) { + args.push('--recursive') + } + + await this.execGit(args) + } + + async submoduleUpdate(fetchDepth: number, recursive: boolean): Promise { + const args = ['-c', 'protocol.version=2'] + args.push('submodule', 'update', '--init', '--force') + if (fetchDepth > 0) { + args.push(`--depth=${fetchDepth}`) + } + + if (recursive) { + args.push('--recursive') + } + + await this.execGit(args) + } + async tagExists(pattern: string): Promise { const output = await this.execGit(['tag', '--list', pattern]) return !!output.stdout.trim() @@ -222,9 +285,17 @@ class GitCommandManager { return output.exitCode === 0 } - async tryConfigUnset(configKey: string): Promise { + async tryConfigUnset( + configKey: string, + globalConfig?: boolean + ): Promise { const output = await this.execGit( - ['config', '--local', '--unset-all', configKey], + [ + 'config', + globalConfig ? '--global' : '--local', + '--unset-all', + configKey + ], true ) return output.exitCode === 0 diff --git a/src/git-source-provider.ts b/src/git-source-provider.ts index 34b822c..90f97c9 100644 --- a/src/git-source-provider.ts +++ b/src/git-source-provider.ts @@ -61,64 +61,92 @@ export async function getSource(settings: IGitSourceSettings): Promise { settings.commit, settings.repositoryPath ) - } else { - // Save state for POST action - stateHelper.setRepositoryPath(settings.repositoryPath) - - // Initialize the repository - if ( - !fsHelper.directoryExistsSync(path.join(settings.repositoryPath, '.git')) - ) { - await git.init() - await git.remoteAdd('origin', repositoryUrl) - } + return + } - // 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.` - ) - } + // Save state for POST action + stateHelper.setRepositoryPath(settings.repositoryPath) - const authHelper = gitAuthHelper.createAuthHelper(git, settings) - try { - // Configure auth - await authHelper.configureAuth() + // Initialize the repository + if ( + !fsHelper.directoryExistsSync(path.join(settings.repositoryPath, '.git')) + ) { + await git.init() + await git.remoteAdd('origin', repositoryUrl) + } - // LFS install - if (settings.lfs) { - await git.lfsInstall() - } + // 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.` + ) + } - // 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) - } + const authHelper = gitAuthHelper.createAuthHelper(git, settings) + try { + // Configure auth + await authHelper.configureAuth() - // Checkout - await git.checkout(checkoutInfo.ref, checkoutInfo.startPoint) + // 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) + } - // Dump some info about the checked out commit - await git.log1() - } finally { - // Remove auth - if (!settings.persistCredentials) { - await authHelper.removeAuth() + // Checkout + await git.checkout(checkoutInfo.ref, checkoutInfo.startPoint) + + // Submodules + if (settings.submodules) { + try { + // Temporarily override global config + await authHelper.configureGlobalAuth() + + // Checkout submodules + await git.submoduleSync(settings.nestedSubmodules) + await git.submoduleUpdate( + settings.fetchDepth, + settings.nestedSubmodules + ) + await git.submoduleForeach( + 'git config --local gc.auto 0', + settings.nestedSubmodules + ) + + // Persist credentials + if (settings.persistCredentials) { + await authHelper.configureSubmoduleAuth() + } + } finally { + // Remove temporary global config override + await authHelper.removeGlobalAuth() } } + + // Dump some info about the checked out commit + await git.log1() + } finally { + // Remove auth + if (!settings.persistCredentials) { + await authHelper.removeAuth() + } } } diff --git a/src/git-source-settings.ts b/src/git-source-settings.ts index 49d8825..e411fad 100644 --- a/src/git-source-settings.ts +++ b/src/git-source-settings.ts @@ -7,6 +7,8 @@ export interface IGitSourceSettings { clean: boolean fetchDepth: number lfs: boolean + submodules: boolean + nestedSubmodules: boolean authToken: string persistCredentials: boolean } diff --git a/src/input-helper.ts b/src/input-helper.ts index e50dbc5..3769350 100644 --- a/src/input-helper.ts +++ b/src/input-helper.ts @@ -85,13 +85,6 @@ export function getInputs(): IGitSourceSettings { 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) { @@ -103,6 +96,19 @@ export function getInputs(): IGitSourceSettings { result.lfs = (core.getInput('lfs') || 'false').toUpperCase() === 'TRUE' core.debug(`lfs = ${result.lfs}`) + // Submodules + result.submodules = false + result.nestedSubmodules = false + const submodulesString = (core.getInput('submodules') || '').toUpperCase() + if (submodulesString == 'RECURSIVE') { + result.submodules = true + result.nestedSubmodules = true + } else if (submodulesString == 'TRUE') { + result.submodules = true + } + core.debug(`submodules = ${result.submodules}`) + core.debug(`recursive submodules = ${result.nestedSubmodules}`) + // Auth token result.authToken = core.getInput('token') diff --git a/src/regexp-helper.ts b/src/regexp-helper.ts new file mode 100644 index 0000000..ec76c3a --- /dev/null +++ b/src/regexp-helper.ts @@ -0,0 +1,5 @@ +export function escape(value: string): string { + return value.replace(/[^a-zA-Z0-9_]/g, x => { + return `\\${x}` + }) +}