diff --git a/__test__/git-auth-helper.test.ts b/__test__/git-auth-helper.test.ts index 7633704..9acba54 100644 --- a/__test__/git-auth-helper.test.ts +++ b/__test__/git-auth-helper.test.ts @@ -675,6 +675,283 @@ describe('git-auth-helper tests', () => { expect(gitConfigContent.indexOf('http.')).toBeLessThan(0) }) + const removeAuth_removesV6StyleCredentials = + 'removeAuth removes v6 style credentials' + it(removeAuth_removesV6StyleCredentials, async () => { + // Arrange + await setup(removeAuth_removesV6StyleCredentials) + const authHelper = gitAuthHelper.createAuthHelper(git, settings) + await authHelper.configureAuth() + + // Manually create v6-style credentials that would be left by v6 + const credentialsFileName = + 'git-credentials-12345678-1234-1234-1234-123456789abc.config' + const credentialsFilePath = path.join(runnerTemp, credentialsFileName) + const basicCredential = Buffer.from( + `x-access-token:${settings.authToken}`, + 'utf8' + ).toString('base64') + const credentialsContent = `[http "https://github.com/"]\n\textraheader = AUTHORIZATION: basic ${basicCredential}\n` + await fs.promises.writeFile(credentialsFilePath, credentialsContent) + + // Add includeIf entries to local git config (simulating v6 configuration) + const hostGitDir = path.join(workspace, '.git').replace(/\\/g, '/') + await fs.promises.appendFile( + localGitConfigPath, + `[includeIf "gitdir:${hostGitDir}/"]\n\tpath = ${credentialsFilePath}\n` + ) + await fs.promises.appendFile( + localGitConfigPath, + `[includeIf "gitdir:/github/workspace/.git/"]\n\tpath = /github/runner_temp/${credentialsFileName}\n` + ) + + // Verify v6 style config exists + let gitConfigContent = ( + await fs.promises.readFile(localGitConfigPath) + ).toString() + expect(gitConfigContent.indexOf('includeIf')).toBeGreaterThanOrEqual(0) + expect( + gitConfigContent.indexOf(credentialsFilePath) + ).toBeGreaterThanOrEqual(0) + await fs.promises.stat(credentialsFilePath) // Verify file exists + + // Mock the git methods to handle v6 cleanup + const mockTryGetConfigKeys = git.tryGetConfigKeys as jest.Mock + mockTryGetConfigKeys.mockResolvedValue([ + `includeIf.gitdir:${hostGitDir}/.path`, + 'includeIf.gitdir:/github/workspace/.git/.path' + ]) + + const mockTryGetConfigValues = git.tryGetConfigValues as jest.Mock + mockTryGetConfigValues.mockImplementation(async (key: string) => { + if (key === `includeIf.gitdir:${hostGitDir}/.path`) { + return [credentialsFilePath] + } + if (key === 'includeIf.gitdir:/github/workspace/.git/.path') { + return [`/github/runner_temp/${credentialsFileName}`] + } + return [] + }) + + const mockTryConfigUnsetValue = git.tryConfigUnsetValue as jest.Mock< + any, + any + > + mockTryConfigUnsetValue.mockImplementation( + async ( + key: string, + value: string, + globalConfig?: boolean, + configPath?: string + ) => { + const targetPath = configPath || localGitConfigPath + let content = await fs.promises.readFile(targetPath, 'utf8') + // Remove the includeIf section + const lines = content + .split('\n') + .filter(line => !line.includes('includeIf') && !line.includes(value)) + await fs.promises.writeFile(targetPath, lines.join('\n')) + return true + } + ) + + // Act + await authHelper.removeAuth() + + // Assert includeIf entries removed from local git config + gitConfigContent = ( + await fs.promises.readFile(localGitConfigPath) + ).toString() + expect(gitConfigContent.indexOf('includeIf')).toBeLessThan(0) + expect(gitConfigContent.indexOf(credentialsFilePath)).toBeLessThan(0) + + // Assert credentials config file deleted + try { + await fs.promises.stat(credentialsFilePath) + throw new Error('Credentials file should have been deleted') + } catch (err) { + if ((err as any)?.code !== 'ENOENT') { + throw err + } + } + }) + + const removeAuth_removesV6StyleCredentialsFromSubmodules = + 'removeAuth removes v6 style credentials from submodules' + it(removeAuth_removesV6StyleCredentialsFromSubmodules, async () => { + // Arrange + await setup(removeAuth_removesV6StyleCredentialsFromSubmodules) + + // Create fake submodule config paths + const submodule1Dir = path.join(workspace, '.git', 'modules', 'submodule-1') + const submodule1ConfigPath = path.join(submodule1Dir, 'config') + await fs.promises.mkdir(submodule1Dir, {recursive: true}) + await fs.promises.writeFile(submodule1ConfigPath, '') + + const authHelper = gitAuthHelper.createAuthHelper(git, settings) + await authHelper.configureAuth() + + // Create v6-style credentials file + const credentialsFileName = + 'git-credentials-abcdef12-3456-7890-abcd-ef1234567890.config' + const credentialsFilePath = path.join(runnerTemp, credentialsFileName) + const basicCredential = Buffer.from( + `x-access-token:${settings.authToken}`, + 'utf8' + ).toString('base64') + const credentialsContent = `[http "https://github.com/"]\n\textraheader = AUTHORIZATION: basic ${basicCredential}\n` + await fs.promises.writeFile(credentialsFilePath, credentialsContent) + + // Add includeIf entries to submodule config + const submodule1GitDir = submodule1Dir.replace(/\\/g, '/') + await fs.promises.appendFile( + submodule1ConfigPath, + `[includeIf "gitdir:${submodule1GitDir}/"]\n\tpath = ${credentialsFilePath}\n` + ) + + // Verify submodule config has includeIf entry + let submoduleConfigContent = ( + await fs.promises.readFile(submodule1ConfigPath) + ).toString() + expect(submoduleConfigContent.indexOf('includeIf')).toBeGreaterThanOrEqual( + 0 + ) + expect( + submoduleConfigContent.indexOf(credentialsFilePath) + ).toBeGreaterThanOrEqual(0) + + // Mock getSubmoduleConfigPaths + const mockGetSubmoduleConfigPaths = + git.getSubmoduleConfigPaths as jest.Mock + mockGetSubmoduleConfigPaths.mockResolvedValue([submodule1ConfigPath]) + + // Mock tryGetConfigKeys for submodule + const mockTryGetConfigKeys = git.tryGetConfigKeys as jest.Mock + mockTryGetConfigKeys.mockImplementation( + async (pattern: string, globalConfig?: boolean, configPath?: string) => { + if (configPath === submodule1ConfigPath) { + return [`includeIf.gitdir:${submodule1GitDir}/.path`] + } + return [] + } + ) + + // Mock tryGetConfigValues for submodule + const mockTryGetConfigValues = git.tryGetConfigValues as jest.Mock + mockTryGetConfigValues.mockImplementation( + async (key: string, globalConfig?: boolean, configPath?: string) => { + if ( + configPath === submodule1ConfigPath && + key === `includeIf.gitdir:${submodule1GitDir}/.path` + ) { + return [credentialsFilePath] + } + return [] + } + ) + + // Mock tryConfigUnsetValue for submodule + const mockTryConfigUnsetValue = git.tryConfigUnsetValue as jest.Mock< + any, + any + > + mockTryConfigUnsetValue.mockImplementation( + async ( + key: string, + value: string, + globalConfig?: boolean, + configPath?: string + ) => { + const targetPath = configPath || localGitConfigPath + let content = await fs.promises.readFile(targetPath, 'utf8') + const lines = content + .split('\n') + .filter(line => !line.includes('includeIf') && !line.includes(value)) + await fs.promises.writeFile(targetPath, lines.join('\n')) + return true + } + ) + + // Act + await authHelper.removeAuth() + + // Assert submodule includeIf entries removed + submoduleConfigContent = ( + await fs.promises.readFile(submodule1ConfigPath) + ).toString() + expect(submoduleConfigContent.indexOf('includeIf')).toBeLessThan(0) + expect(submoduleConfigContent.indexOf(credentialsFilePath)).toBeLessThan(0) + + // Assert credentials file deleted + try { + await fs.promises.stat(credentialsFilePath) + throw new Error('Credentials file should have been deleted') + } catch (err) { + if ((err as any)?.code !== 'ENOENT') { + throw err + } + } + }) + + const removeAuth_skipsV6CleanupWhenEnvVarSet = + 'removeAuth skips v6 cleanup when ACTIONS_CHECKOUT_SKIP_V6_CLEANUP is set' + it(removeAuth_skipsV6CleanupWhenEnvVarSet, async () => { + // Arrange + await setup(removeAuth_skipsV6CleanupWhenEnvVarSet) + + // Set the skip environment variable + process.env['ACTIONS_CHECKOUT_SKIP_V6_CLEANUP'] = '1' + + const authHelper = gitAuthHelper.createAuthHelper(git, settings) + await authHelper.configureAuth() + + // Create v6-style credentials file in RUNNER_TEMP + const credentialsFileName = 'git-credentials-test-uuid-1234-5678.config' + const credentialsFilePath = path.join(runnerTemp, credentialsFileName) + const credentialsContent = + '[http "https://github.com/"]\n\textraheader = AUTHORIZATION: basic token\n' + await fs.promises.writeFile(credentialsFilePath, credentialsContent) + + // Add includeIf section to local git config (separate from http.* config) + const includeIfSection = `\n[includeIf "gitdir:/some/path/.git/"]\n\tpath = ${credentialsFilePath}\n` + await fs.promises.appendFile(localGitConfigPath, includeIfSection) + + // Verify v6 style config exists + let gitConfigContent = ( + await fs.promises.readFile(localGitConfigPath) + ).toString() + expect(gitConfigContent.indexOf('includeIf')).toBeGreaterThanOrEqual(0) + await fs.promises.stat(credentialsFilePath) // Verify file exists + + // Act + await authHelper.removeAuth() + + // Assert v5 cleanup still happened (http.* removed) + gitConfigContent = ( + await fs.promises.readFile(localGitConfigPath) + ).toString() + expect( + gitConfigContent.indexOf('http.https://github.com/.extraheader') + ).toBeLessThan(0) + + // Assert v6 cleanup was skipped - includeIf should still be present + expect(gitConfigContent.indexOf('includeIf')).toBeGreaterThanOrEqual(0) + expect( + gitConfigContent.indexOf(credentialsFilePath) + ).toBeGreaterThanOrEqual(0) + + // Assert credentials file still exists (wasn't deleted) + await fs.promises.stat(credentialsFilePath) // File should still exist + + // Assert debug message was logged + expect(core.debug).toHaveBeenCalledWith( + 'Skipping v6 style cleanup due to ACTIONS_CHECKOUT_SKIP_V6_CLEANUP' + ) + + // Cleanup + delete process.env['ACTIONS_CHECKOUT_SKIP_V6_CLEANUP'] + }) + const removeGlobalConfig_removesOverride = 'removeGlobalConfig removes override' it(removeGlobalConfig_removesOverride, async () => { @@ -796,6 +1073,18 @@ async function setup(testName: string): Promise { ), tryDisableAutomaticGarbageCollection: jest.fn(), tryGetFetchUrl: jest.fn(), + getSubmoduleConfigPaths: jest.fn(async () => { + return [] + }), + tryConfigUnsetValue: jest.fn(async () => { + return true + }), + tryGetConfigValues: jest.fn(async () => { + return [] + }), + tryGetConfigKeys: jest.fn(async () => { + return [] + }), tryReset: jest.fn(), version: jest.fn() } diff --git a/__test__/git-directory-helper.test.ts b/__test__/git-directory-helper.test.ts index 22e9ae6..1627b84 100644 --- a/__test__/git-directory-helper.test.ts +++ b/__test__/git-directory-helper.test.ts @@ -499,6 +499,18 @@ async function setup(testName: string): Promise { await fs.promises.stat(path.join(repositoryPath, '.git')) return repositoryUrl }), + getSubmoduleConfigPaths: jest.fn(async () => { + return [] + }), + tryConfigUnsetValue: jest.fn(async () => { + return true + }), + tryGetConfigValues: jest.fn(async () => { + return [] + }), + tryGetConfigKeys: jest.fn(async () => { + return [] + }), tryReset: jest.fn(async () => { return true }), diff --git a/dist/index.js b/dist/index.js index f3ae6f3..b0add8a 100644 --- a/dist/index.js +++ b/dist/index.js @@ -411,8 +411,50 @@ class GitAuthHelper { } removeToken() { return __awaiter(this, void 0, void 0, function* () { - // HTTP extra header + // Remove HTTP extra header from local git config and submodule configs yield this.removeGitConfig(this.tokenConfigKey); + // + // Cleanup actions/checkout@v6 style credentials + // + const skipV6Cleanup = process.env['ACTIONS_CHECKOUT_SKIP_V6_CLEANUP']; + if (skipV6Cleanup === '1' || (skipV6Cleanup === null || skipV6Cleanup === void 0 ? void 0 : skipV6Cleanup.toLowerCase()) === 'true') { + core.debug('Skipping v6 style cleanup due to ACTIONS_CHECKOUT_SKIP_V6_CLEANUP'); + return; + } + try { + // Collect credentials config paths that need to be removed + const credentialsPaths = new Set(); + // Remove includeIf entries that point to git-credentials-*.config files + const mainCredentialsPaths = yield this.removeIncludeIfCredentials(); + mainCredentialsPaths.forEach(path => credentialsPaths.add(path)); + // Remove submodule includeIf entries that point to git-credentials-*.config files + try { + const submoduleConfigPaths = yield this.git.getSubmoduleConfigPaths(true); + for (const configPath of submoduleConfigPaths) { + const submoduleCredentialsPaths = yield this.removeIncludeIfCredentials(configPath); + submoduleCredentialsPaths.forEach(path => credentialsPaths.add(path)); + } + } + catch (err) { + core.debug(`Unable to get submodule config paths: ${err}`); + } + // Remove credentials config files + for (const credentialsPath of credentialsPaths) { + // Only remove credentials config files if they are under RUNNER_TEMP + const runnerTemp = process.env['RUNNER_TEMP']; + if (runnerTemp && credentialsPath.startsWith(runnerTemp)) { + try { + yield io.rmRF(credentialsPath); + } + catch (err) { + core.debug(`Failed to remove credentials config '${credentialsPath}': ${err}`); + } + } + } + } + catch (err) { + core.debug(`Failed to cleanup v6 style credentials: ${err}`); + } }); } removeGitConfig(configKey_1) { @@ -430,6 +472,49 @@ class GitAuthHelper { `sh -c "git config --local --name-only --get-regexp '${pattern}' && git config --local --unset-all '${configKey}' || :"`, true); }); } + /** + * Removes includeIf entries that point to git-credentials-*.config files. + * This handles cleanup of credentials configured by newer versions of the action. + * @param configPath Optional path to a specific git config file to operate on + * @returns Array of unique credentials config file paths that were found and removed + */ + removeIncludeIfCredentials(configPath) { + return __awaiter(this, void 0, void 0, function* () { + const credentialsPaths = new Set(); + try { + // Get all includeIf.gitdir keys + const keys = yield this.git.tryGetConfigKeys('^includeIf\\.gitdir:', false, // globalConfig? + configPath); + for (const key of keys) { + // Get all values for this key + const values = yield this.git.tryGetConfigValues(key, false, // globalConfig? + configPath); + if (values.length > 0) { + // Remove only values that match git-credentials-.config pattern + for (const value of values) { + if (this.testCredentialsConfigPath(value)) { + credentialsPaths.add(value); + yield this.git.tryConfigUnsetValue(key, value, false, configPath); + } + } + } + } + } + catch (err) { + // Ignore errors - this is cleanup code + core.debug(`Error during includeIf cleanup${configPath ? ` for ${configPath}` : ''}: ${err}`); + } + return Array.from(credentialsPaths); + }); + } + /** + * Tests if a path matches the git-credentials-*.config pattern used by newer versions. + * @param path The path to test + * @returns True if the path matches the credentials config pattern + */ + testCredentialsConfigPath(path) { + return /git-credentials-[0-9a-f-]+\.config$/i.test(path); + } } @@ -706,6 +791,16 @@ class GitCommandManager { throw new Error('Unexpected output when retrieving default branch'); }); } + getSubmoduleConfigPaths(recursive) { + return __awaiter(this, void 0, void 0, function* () { + // Get submodule config file paths. + // Use `--show-origin` to get the config file path for each submodule. + const output = yield this.submoduleForeach(`git config --local --show-origin --name-only --get-regexp remote.origin.url`, recursive); + // Extract config file paths from the output (lines starting with "file:"). + const configPaths = output.match(/(?<=(^|\n)file:)[^\t]+(?=\tremote\.origin\.url)/g) || []; + return configPaths; + }); + } getWorkingDirectory() { return this.workingDirectory; } @@ -836,6 +931,20 @@ class GitCommandManager { return output.exitCode === 0; }); } + tryConfigUnsetValue(configKey, configValue, globalConfig, configFile) { + return __awaiter(this, void 0, void 0, function* () { + const args = ['config']; + if (configFile) { + args.push('--file', configFile); + } + else { + args.push(globalConfig ? '--global' : '--local'); + } + args.push('--unset', configKey, configValue); + const output = yield this.execGit(args, true); + return output.exitCode === 0; + }); + } tryDisableAutomaticGarbageCollection() { return __awaiter(this, void 0, void 0, function* () { const output = yield this.execGit(['config', '--local', 'gc.auto', '0'], true); @@ -855,6 +964,46 @@ class GitCommandManager { return stdout; }); } + tryGetConfigValues(configKey, globalConfig, configFile) { + return __awaiter(this, void 0, void 0, function* () { + const args = ['config']; + if (configFile) { + args.push('--file', configFile); + } + else { + args.push(globalConfig ? '--global' : '--local'); + } + args.push('--get-all', configKey); + const output = yield this.execGit(args, true); + if (output.exitCode !== 0) { + return []; + } + return output.stdout + .trim() + .split('\n') + .filter(value => value.trim()); + }); + } + tryGetConfigKeys(pattern, globalConfig, configFile) { + return __awaiter(this, void 0, void 0, function* () { + const args = ['config']; + if (configFile) { + args.push('--file', configFile); + } + else { + args.push(globalConfig ? '--global' : '--local'); + } + args.push('--name-only', '--get-regexp', pattern); + const output = yield this.execGit(args, true); + if (output.exitCode !== 0) { + return []; + } + return output.stdout + .trim() + .split('\n') + .filter(key => key.trim()); + }); + } tryReset() { return __awaiter(this, void 0, void 0, function* () { const output = yield this.execGit(['reset', '--hard', 'HEAD'], true); diff --git a/src/git-auth-helper.ts b/src/git-auth-helper.ts index 126e8e5..0c82ddd 100644 --- a/src/git-auth-helper.ts +++ b/src/git-auth-helper.ts @@ -346,8 +346,58 @@ class GitAuthHelper { } private async removeToken(): Promise { - // HTTP extra header + // Remove HTTP extra header from local git config and submodule configs await this.removeGitConfig(this.tokenConfigKey) + + // + // Cleanup actions/checkout@v6 style credentials + // + const skipV6Cleanup = process.env['ACTIONS_CHECKOUT_SKIP_V6_CLEANUP'] + if (skipV6Cleanup === '1' || skipV6Cleanup?.toLowerCase() === 'true') { + core.debug( + 'Skipping v6 style cleanup due to ACTIONS_CHECKOUT_SKIP_V6_CLEANUP' + ) + return + } + + try { + // Collect credentials config paths that need to be removed + const credentialsPaths = new Set() + + // Remove includeIf entries that point to git-credentials-*.config files + const mainCredentialsPaths = await this.removeIncludeIfCredentials() + mainCredentialsPaths.forEach(path => credentialsPaths.add(path)) + + // Remove submodule includeIf entries that point to git-credentials-*.config files + try { + const submoduleConfigPaths = + await this.git.getSubmoduleConfigPaths(true) + for (const configPath of submoduleConfigPaths) { + const submoduleCredentialsPaths = + await this.removeIncludeIfCredentials(configPath) + submoduleCredentialsPaths.forEach(path => credentialsPaths.add(path)) + } + } catch (err) { + core.debug(`Unable to get submodule config paths: ${err}`) + } + + // Remove credentials config files + for (const credentialsPath of credentialsPaths) { + // Only remove credentials config files if they are under RUNNER_TEMP + const runnerTemp = process.env['RUNNER_TEMP'] + if (runnerTemp && credentialsPath.startsWith(runnerTemp)) { + try { + await io.rmRF(credentialsPath) + } catch (err) { + core.debug( + `Failed to remove credentials config '${credentialsPath}': ${err}` + ) + } + } + } + } catch (err) { + core.debug(`Failed to cleanup v6 style credentials: ${err}`) + } } private async removeGitConfig( @@ -371,4 +421,59 @@ class GitAuthHelper { true ) } + + /** + * Removes includeIf entries that point to git-credentials-*.config files. + * This handles cleanup of credentials configured by newer versions of the action. + * @param configPath Optional path to a specific git config file to operate on + * @returns Array of unique credentials config file paths that were found and removed + */ + private async removeIncludeIfCredentials( + configPath?: string + ): Promise { + const credentialsPaths = new Set() + + try { + // Get all includeIf.gitdir keys + const keys = await this.git.tryGetConfigKeys( + '^includeIf\\.gitdir:', + false, // globalConfig? + configPath + ) + + for (const key of keys) { + // Get all values for this key + const values = await this.git.tryGetConfigValues( + key, + false, // globalConfig? + configPath + ) + if (values.length > 0) { + // Remove only values that match git-credentials-.config pattern + for (const value of values) { + if (this.testCredentialsConfigPath(value)) { + credentialsPaths.add(value) + await this.git.tryConfigUnsetValue(key, value, false, configPath) + } + } + } + } + } catch (err) { + // Ignore errors - this is cleanup code + core.debug( + `Error during includeIf cleanup${configPath ? ` for ${configPath}` : ''}: ${err}` + ) + } + + return Array.from(credentialsPaths) + } + + /** + * Tests if a path matches the git-credentials-*.config pattern used by newer versions. + * @param path The path to test + * @returns True if the path matches the credentials config pattern + */ + private testCredentialsConfigPath(path: string): boolean { + return /git-credentials-[0-9a-f-]+\.config$/i.test(path) + } } diff --git a/src/git-command-manager.ts b/src/git-command-manager.ts index 8e42a38..9c789ac 100644 --- a/src/git-command-manager.ts +++ b/src/git-command-manager.ts @@ -41,6 +41,7 @@ export interface IGitCommandManager { } ): Promise getDefaultBranch(repositoryUrl: string): Promise + getSubmoduleConfigPaths(recursive: boolean): Promise getWorkingDirectory(): string init(): Promise isDetached(): Promise @@ -59,8 +60,24 @@ export interface IGitCommandManager { tagExists(pattern: string): Promise tryClean(): Promise tryConfigUnset(configKey: string, globalConfig?: boolean): Promise + tryConfigUnsetValue( + configKey: string, + configValue: string, + globalConfig?: boolean, + configFile?: string + ): Promise tryDisableAutomaticGarbageCollection(): Promise tryGetFetchUrl(): Promise + tryGetConfigValues( + configKey: string, + globalConfig?: boolean, + configFile?: string + ): Promise + tryGetConfigKeys( + pattern: string, + globalConfig?: boolean, + configFile?: string + ): Promise tryReset(): Promise version(): Promise } @@ -323,6 +340,21 @@ class GitCommandManager { throw new Error('Unexpected output when retrieving default branch') } + async getSubmoduleConfigPaths(recursive: boolean): Promise { + // Get submodule config file paths. + // Use `--show-origin` to get the config file path for each submodule. + const output = await this.submoduleForeach( + `git config --local --show-origin --name-only --get-regexp remote.origin.url`, + recursive + ) + + // Extract config file paths from the output (lines starting with "file:"). + const configPaths = + output.match(/(?<=(^|\n)file:)[^\t]+(?=\tremote\.origin\.url)/g) || [] + + return configPaths + } + getWorkingDirectory(): string { return this.workingDirectory } @@ -455,6 +487,24 @@ class GitCommandManager { return output.exitCode === 0 } + async tryConfigUnsetValue( + configKey: string, + configValue: string, + globalConfig?: boolean, + configFile?: string + ): Promise { + const args = ['config'] + if (configFile) { + args.push('--file', configFile) + } else { + args.push(globalConfig ? '--global' : '--local') + } + args.push('--unset', configKey, configValue) + + const output = await this.execGit(args, true) + return output.exitCode === 0 + } + async tryDisableAutomaticGarbageCollection(): Promise { const output = await this.execGit( ['config', '--local', 'gc.auto', '0'], @@ -481,6 +531,56 @@ class GitCommandManager { return stdout } + async tryGetConfigValues( + configKey: string, + globalConfig?: boolean, + configFile?: string + ): Promise { + const args = ['config'] + if (configFile) { + args.push('--file', configFile) + } else { + args.push(globalConfig ? '--global' : '--local') + } + args.push('--get-all', configKey) + + const output = await this.execGit(args, true) + + if (output.exitCode !== 0) { + return [] + } + + return output.stdout + .trim() + .split('\n') + .filter(value => value.trim()) + } + + async tryGetConfigKeys( + pattern: string, + globalConfig?: boolean, + configFile?: string + ): Promise { + const args = ['config'] + if (configFile) { + args.push('--file', configFile) + } else { + args.push(globalConfig ? '--global' : '--local') + } + args.push('--name-only', '--get-regexp', pattern) + + const output = await this.execGit(args, true) + + if (output.exitCode !== 0) { + return [] + } + + return output.stdout + .trim() + .split('\n') + .filter(key => key.trim()) + } + async tryReset(): Promise { const output = await this.execGit(['reset', '--hard', 'HEAD'], true) return output.exitCode === 0