From f8060825ea6d5d4e1f8f8464ed9d9d0380dc1e7b Mon Sep 17 00:00:00 2001 From: eric sciple Date: Fri, 17 Oct 2025 00:02:33 +0000 Subject: [PATCH] . --- __test__/git-auth-helper.test.ts | 41 +++-- __test__/git-directory-helper.test.ts | 1 + dist/index.js | 213 ++++++++++++++++---------- src/git-auth-helper.ts | 172 ++++++++++++--------- src/git-command-manager.ts | 87 ++++++----- 5 files changed, 320 insertions(+), 194 deletions(-) diff --git a/__test__/git-auth-helper.test.ts b/__test__/git-auth-helper.test.ts index f8700ee..1bcc140 100644 --- a/__test__/git-auth-helper.test.ts +++ b/__test__/git-auth-helper.test.ts @@ -595,18 +595,15 @@ describe('git-auth-helper tests', () => { await authHelper.configureSubmoduleAuth() // Assert - // Should get submodule config paths (1 call) and configure insteadOf (2 calls for two values) - expect(mockSubmoduleForeach).toHaveBeenCalledTimes(4) + // Should configure insteadOf (2 calls for two values) + expect(mockSubmoduleForeach).toHaveBeenCalledTimes(3) expect(mockSubmoduleForeach.mock.calls[0][0]).toMatch( /unset-all.*insteadOf/ ) expect(mockSubmoduleForeach.mock.calls[1][0]).toMatch( - /show-origin.*remote\.origin\.url/ - ) - expect(mockSubmoduleForeach.mock.calls[2][0]).toMatch( /url.*insteadOf.*git@github.com:/ ) - expect(mockSubmoduleForeach.mock.calls[3][0]).toMatch( + expect(mockSubmoduleForeach.mock.calls[2][0]).toMatch( /url.*insteadOf.*org-123456@github.com:/ ) } @@ -637,15 +634,12 @@ describe('git-auth-helper tests', () => { await authHelper.configureSubmoduleAuth() // Assert - // Should get submodule config paths (1 call) and configure sshCommand (1 call) - expect(mockSubmoduleForeach).toHaveBeenCalledTimes(3) + // Should configure sshCommand (1 call) + expect(mockSubmoduleForeach).toHaveBeenCalledTimes(2) expect(mockSubmoduleForeach.mock.calls[0][0]).toMatch( /unset-all.*insteadOf/ ) - expect(mockSubmoduleForeach.mock.calls[1][0]).toMatch( - /show-origin.*remote\.origin\.url/ - ) - expect(mockSubmoduleForeach.mock.calls[2][0]).toMatch(/core\.sshCommand/) + expect(mockSubmoduleForeach.mock.calls[1][0]).toMatch(/core\.sshCommand/) } ) @@ -768,6 +762,28 @@ describe('git-auth-helper tests', () => { } } }) + + const testCredentialsConfigPath_matchesCredentialsConfigPaths = + 'testCredentialsConfigPath matches credentials config paths' + it(testCredentialsConfigPath_matchesCredentialsConfigPaths, async () => { + // Arrange + await setup(testCredentialsConfigPath_matchesCredentialsConfigPaths) + const authHelper = gitAuthHelper.createAuthHelper(git, settings) + + // Get a real credentials config path + const credentialsConfigPath = await (authHelper as any).getCredentialsConfigPath() + + // Act & Assert + expect((authHelper as any).testCredentialsConfigPath(credentialsConfigPath)).toBe(true) + expect((authHelper as any).testCredentialsConfigPath('/some/path/git-credentials-12345678-abcd-1234-5678-123456789012.config')).toBe(true) + expect((authHelper as any).testCredentialsConfigPath('/some/path/git-credentials-abcdef12-3456-7890-abcd-ef1234567890.config')).toBe(true) + + // Test invalid paths + expect((authHelper as any).testCredentialsConfigPath('/some/path/other-config.config')).toBe(false) + expect((authHelper as any).testCredentialsConfigPath('/some/path/git-credentials-invalid.config')).toBe(false) + expect((authHelper as any).testCredentialsConfigPath('/some/path/git-credentials-.config')).toBe(false) + expect((authHelper as any).testCredentialsConfigPath('')).toBe(false) + }) }) async function setup(testName: string): Promise { @@ -834,6 +850,7 @@ async function setup(testName: string): Promise { env: {}, fetch: jest.fn(), getDefaultBranch: jest.fn(), + getSubmoduleConfigPaths: jest.fn(async () => []), getWorkingDirectory: jest.fn(() => workspace), init: jest.fn(), isDetached: jest.fn(), diff --git a/__test__/git-directory-helper.test.ts b/__test__/git-directory-helper.test.ts index d728e28..de79dc8 100644 --- a/__test__/git-directory-helper.test.ts +++ b/__test__/git-directory-helper.test.ts @@ -471,6 +471,7 @@ async function setup(testName: string): Promise { configExists: jest.fn(), fetch: jest.fn(), getDefaultBranch: jest.fn(), + getSubmoduleConfigPaths: jest.fn(async () => []), getWorkingDirectory: jest.fn(() => repositoryPath), init: jest.fn(), isDetached: jest.fn(), diff --git a/dist/index.js b/dist/index.js index e865a0d..3d2b9ca 100644 --- a/dist/index.js +++ b/dist/index.js @@ -163,7 +163,6 @@ class GitAuthHelper { this.sshKnownHostsPath = ''; this.temporaryHomePath = ''; this.credentialsConfigPath = ''; // Path to separate credentials config file in RUNNER_TEMP - this.credentialsIncludeKeys = []; // Track includeIf config keys for cleanup this.git = gitCommandManager; this.settings = gitSourceSettings || {}; // Token auth header @@ -189,20 +188,6 @@ class GitAuthHelper { yield this.configureToken(); }); } - getCredentialsConfigPath() { - return __awaiter(this, void 0, void 0, function* () { - if (this.credentialsConfigPath) { - return this.credentialsConfigPath; - } - const runnerTemp = process.env['RUNNER_TEMP'] || ''; - assert.ok(runnerTemp, 'RUNNER_TEMP is not defined'); - // Create a unique filename for this checkout instance - const configFileName = `git-credentials-${(0, uuid_1.v4)()}.config`; - this.credentialsConfigPath = path.join(runnerTemp, configFileName); - core.debug(`Credentials config path: ${this.credentialsConfigPath}`); - return this.credentialsConfigPath; - }); - } configureTempGlobalConfig() { return __awaiter(this, void 0, void 0, function* () { var _a; @@ -282,10 +267,7 @@ class GitAuthHelper { relativePath = relativePath.replace(/\\/g, '/'); const containerRepoPath = path.posix.join('/github/workspace', relativePath); // Get submodule config file paths. - // Use `--show-origin` to get the config file path for each submodule. - const output = yield this.git.submoduleForeach(`git config --local --show-origin --name-only --get-regexp remote.origin.url`, this.settings.nestedSubmodules); - // Extract config file paths from the output (lines starting with "file:"). - const configPaths = output.match(/(?<=(^|\n)file:)[^\t]+(?=\tremote\.origin\.url)/g) || []; + const configPaths = yield this.git.getSubmoduleConfigPaths(this.settings.nestedSubmodules); // For each submodule, configure includeIf entries pointing to the shared credentials file. // Configure both host and container paths to support Docker container actions. for (const configPath of configPaths) { @@ -329,6 +311,10 @@ class GitAuthHelper { } }); } + /** + * Configures SSH authentication by writing the SSH key and known hosts, + * and setting up the GIT_SSH_COMMAND environment variable. + */ configureSsh() { return __awaiter(this, void 0, void 0, function* () { if (!this.settings.sshKey) { @@ -385,6 +371,11 @@ class GitAuthHelper { } }); } + /** + * Configures token-based authentication by creating a credentials config file + * and setting up includeIf entries to reference it. + * @param globalConfig Whether to configure global config instead of local + */ configureToken(globalConfig) { return __awaiter(this, void 0, void 0, function* () { // Get the credentials config file path in RUNNER_TEMP @@ -395,7 +386,15 @@ class GitAuthHelper { // https://docs.microsoft.com/en-us/windows-server/identity/ad-ds/manage/component-updates/command-line-process-auditing yield this.git.config(this.tokenConfigKey, this.tokenPlaceholderConfigValue, false, false, credentialsConfigPath); // Replace the placeholder in the credentials config file - yield this.replaceTokenPlaceholder(credentialsConfigPath); + let content = (yield fs.promises.readFile(credentialsConfigPath)).toString(); + const placeholderIndex = content.indexOf(this.tokenPlaceholderConfigValue); + if (placeholderIndex < 0 || + placeholderIndex != content.lastIndexOf(this.tokenPlaceholderConfigValue)) { + throw new Error(`Unable to replace auth placeholder in ${credentialsConfigPath}`); + } + assert.ok(this.tokenConfigValue, 'tokenConfigValue is not defined'); + content = content.replace(this.tokenPlaceholderConfigValue, this.tokenConfigValue); + yield fs.promises.writeFile(credentialsConfigPath, content); // Add include or includeIf to reference the credentials config if (globalConfig) { // Global config file is temporary @@ -408,7 +407,6 @@ class GitAuthHelper { // Configure host includeIf const hostIncludeKey = `includeIf.gitdir:${gitDir}.path`; yield this.git.config(hostIncludeKey, credentialsConfigPath); - this.credentialsIncludeKeys.push(hostIncludeKey); // Container git directory const githubWorkspace = process.env['GITHUB_WORKSPACE']; assert.ok(githubWorkspace, 'GITHUB_WORKSPACE is not defined'); @@ -421,24 +419,31 @@ class GitAuthHelper { // Configure container includeIf const containerIncludeKey = `includeIf.gitdir:${containerGitDir}.path`; yield this.git.config(containerIncludeKey, containerCredentialsPath); - this.credentialsIncludeKeys.push(containerIncludeKey); } }); } - replaceTokenPlaceholder(configPath) { + /** + * Gets or creates the path to the credentials config file in RUNNER_TEMP. + * @returns The absolute path to the credentials config file + */ + getCredentialsConfigPath() { 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(this.tokenPlaceholderConfigValue); - if (placeholderIndex < 0 || - placeholderIndex != content.lastIndexOf(this.tokenPlaceholderConfigValue)) { - throw new Error(`Unable to replace auth placeholder in ${configPath}`); + if (this.credentialsConfigPath) { + return this.credentialsConfigPath; } - assert.ok(this.tokenConfigValue, 'tokenConfigValue is not defined'); - content = content.replace(this.tokenPlaceholderConfigValue, this.tokenConfigValue); - yield fs.promises.writeFile(configPath, content); + const runnerTemp = process.env['RUNNER_TEMP'] || ''; + assert.ok(runnerTemp, 'RUNNER_TEMP is not defined'); + // Create a unique filename for this checkout instance + const configFileName = `git-credentials-${(0, uuid_1.v4)()}.config`; + this.credentialsConfigPath = path.join(runnerTemp, configFileName); + core.debug(`Credentials config path: ${this.credentialsConfigPath}`); + return this.credentialsConfigPath; }); } + /** + * Removes SSH authentication configuration by cleaning up SSH keys, + * known hosts files, and SSH command configurations. + */ removeSsh() { return __awaiter(this, void 0, void 0, function* () { var _a; @@ -468,6 +473,10 @@ class GitAuthHelper { yield this.removeSubmoduleGitConfig(SSH_COMMAND_KEY); }); } + /** + * Removes token-based authentication by cleaning up HTTP headers, + * includeIf entries, and credentials config files. + */ removeToken() { return __awaiter(this, void 0, void 0, function* () { var _a; @@ -475,30 +484,12 @@ class GitAuthHelper { yield this.removeGitConfig(this.tokenConfigKey); yield this.removeSubmoduleGitConfig(this.tokenConfigKey); // Remove includeIf entries that point to git-credentials-*.config files - // This is more aggressive than tracking keys, but necessary since cleanup - // runs in a post-step where this.credentialsIncludeKeys is empty - try { - // Get all includeIf.gitdir keys - const keys = yield this.git.tryGetConfigKeys('^includeIf\\.gitdir:'); - for (const key of keys) { - // Get all values for this key - const values = yield this.git.tryGetConfigValues(key); - if (values.length > 0) { - // Remove only values that match git-credentials-.config pattern - for (const value of values) { - if (/git-credentials-[0-9a-f-]+\.config$/i.test(value)) { - yield this.git.tryConfigUnsetValue(key, value); - } - } - } - } + yield this.removeIncludeIfCredentials(); + // Remove submodule includeIf entries that point to git-credentials-*.config files + const submoduleConfigPaths = yield this.git.getSubmoduleConfigPaths(true); + for (const configPath of submoduleConfigPaths) { + yield this.removeIncludeIfCredentials(configPath); } - catch (err) { - // Ignore errors - this is cleanup code - core.debug(`Error during includeIf cleanup: ${err}`); - } - // Remove submodule includeIf - yield this.git.submoduleForeach(`sh -c "git config --local --get-regexp '^includeif\\.' && git config --local --remove-section includeif || :"`, true); // Remove credentials config file if (this.credentialsConfigPath) { try { @@ -511,6 +502,10 @@ class GitAuthHelper { } }); } + /** + * Removes a git config key from the local repository config. + * @param configKey The git config key to remove + */ removeGitConfig(configKey) { return __awaiter(this, void 0, void 0, function* () { if ((yield this.git.configExists(configKey)) && @@ -520,6 +515,10 @@ class GitAuthHelper { } }); } + /** + * Removes a git config key from all submodule configs. + * @param configKey The git config key to remove + */ removeSubmoduleGitConfig(configKey) { return __awaiter(this, void 0, void 0, function* () { const pattern = regexpHelper.escape(configKey); @@ -528,6 +527,47 @@ 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. + * @param configPath Optional path to a specific git config file to operate on + */ + removeIncludeIfCredentials(configPath) { + return __awaiter(this, void 0, void 0, function* () { + try { + // Get all includeIf.gitdir keys + const keys = yield this.git.tryGetConfigKeys('^includeIf\\.gitdir:', false, configPath); + for (const key of keys) { + // Get all values for this key + const values = yield this.git.tryGetConfigValues(key, false, configPath); + if (values.length > 0) { + // Remove only values that match git-credentials-.config pattern + for (const value of values) { + if (this.testCredentialsConfigPath(value)) { + yield this.git.tryConfigUnsetValue(key, value, false, configPath); + } + } + } + } + } + catch (err) { + // Ignore errors - this is cleanup code + if (configPath) { + core.debug(`Error during includeIf cleanup for ${configPath}: ${err}`); + } + else { + core.debug(`Error during includeIf cleanup: ${err}`); + } + } + }); + } + /** + * Tests if a path matches the git-credentials-*.config pattern. + * @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); + } } @@ -810,6 +850,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; } @@ -940,15 +990,17 @@ class GitCommandManager { return output.exitCode === 0; }); } - tryConfigUnsetValue(configKey, configValue, globalConfig) { + tryConfigUnsetValue(configKey, configValue, globalConfig, configFile) { return __awaiter(this, void 0, void 0, function* () { - const output = yield this.execGit([ - 'config', - globalConfig ? '--global' : '--local', - '--unset', - configKey, - configValue - ], true); + 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; }); } @@ -971,29 +1023,34 @@ class GitCommandManager { return stdout; }); } - tryGetConfigValues(configKey, globalConfig) { + tryGetConfigValues(configKey, globalConfig, configFile) { return __awaiter(this, void 0, void 0, function* () { - const output = yield this.execGit([ - 'config', - globalConfig ? '--global' : '--local', - '--get-all', - configKey - ], true); + 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) { + tryGetConfigKeys(pattern, globalConfig, configFile) { return __awaiter(this, void 0, void 0, function* () { - const output = yield this.execGit([ - 'config', - globalConfig ? '--global' : '--local', - '--name-only', - '--get-regexp', - pattern - ], true); + 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 []; } diff --git a/src/git-auth-helper.ts b/src/git-auth-helper.ts index 3576ba9..245962e 100644 --- a/src/git-auth-helper.ts +++ b/src/git-auth-helper.ts @@ -44,7 +44,6 @@ class GitAuthHelper { private sshKnownHostsPath = '' private temporaryHomePath = '' private credentialsConfigPath = '' // Path to separate credentials config file in RUNNER_TEMP - private credentialsIncludeKeys: string[] = [] // Track includeIf config keys for cleanup constructor( gitCommandManager: IGitCommandManager, @@ -83,22 +82,6 @@ class GitAuthHelper { await this.configureToken() } - private async getCredentialsConfigPath(): Promise { - if (this.credentialsConfigPath) { - return this.credentialsConfigPath - } - - const runnerTemp = process.env['RUNNER_TEMP'] || '' - assert.ok(runnerTemp, 'RUNNER_TEMP is not defined') - - // Create a unique filename for this checkout instance - const configFileName = `git-credentials-${uuid()}.config` - this.credentialsConfigPath = path.join(runnerTemp, configFileName) - - core.debug(`Credentials config path: ${this.credentialsConfigPath}`) - return this.credentialsConfigPath - } - async configureTempGlobalConfig(): Promise { // Already setup global config if (this.temporaryHomePath?.length > 0) { @@ -192,16 +175,10 @@ class GitAuthHelper { ) // Get submodule config file paths. - // Use `--show-origin` to get the config file path for each submodule. - const output = await this.git.submoduleForeach( - `git config --local --show-origin --name-only --get-regexp remote.origin.url`, + const configPaths = await this.git.getSubmoduleConfigPaths( this.settings.nestedSubmodules ) - // Extract config file paths from the output (lines starting with "file:"). - const configPaths = - output.match(/(?<=(^|\n)file:)[^\t]+(?=\tremote\.origin\.url)/g) || [] - // For each submodule, configure includeIf entries pointing to the shared credentials file. // Configure both host and container paths to support Docker container actions. for (const configPath of configPaths) { @@ -268,6 +245,10 @@ class GitAuthHelper { } } + /** + * Configures SSH authentication by writing the SSH key and known hosts, + * and setting up the GIT_SSH_COMMAND environment variable. + */ private async configureSsh(): Promise { if (!this.settings.sshKey) { return @@ -339,6 +320,11 @@ class GitAuthHelper { } } + /** + * Configures token-based authentication by creating a credentials config file + * and setting up includeIf entries to reference it. + * @param globalConfig Whether to configure global config instead of local + */ private async configureToken(globalConfig?: boolean): Promise { // Get the credentials config file path in RUNNER_TEMP const credentialsConfigPath = await this.getCredentialsConfigPath() @@ -356,7 +342,20 @@ class GitAuthHelper { ) // Replace the placeholder in the credentials config file - await this.replaceTokenPlaceholder(credentialsConfigPath) + let content = (await fs.promises.readFile(credentialsConfigPath)).toString() + const placeholderIndex = content.indexOf(this.tokenPlaceholderConfigValue) + if ( + placeholderIndex < 0 || + placeholderIndex != content.lastIndexOf(this.tokenPlaceholderConfigValue) + ) { + throw new Error(`Unable to replace auth placeholder in ${credentialsConfigPath}`) + } + assert.ok(this.tokenConfigValue, 'tokenConfigValue is not defined') + content = content.replace( + this.tokenPlaceholderConfigValue, + this.tokenConfigValue + ) + await fs.promises.writeFile(credentialsConfigPath, content) // Add include or includeIf to reference the credentials config if (globalConfig) { @@ -370,7 +369,6 @@ class GitAuthHelper { // Configure host includeIf const hostIncludeKey = `includeIf.gitdir:${gitDir}.path` await this.git.config(hostIncludeKey, credentialsConfigPath) - this.credentialsIncludeKeys.push(hostIncludeKey) // Container git directory const githubWorkspace = process.env['GITHUB_WORKSPACE'] @@ -393,28 +391,33 @@ class GitAuthHelper { // Configure container includeIf const containerIncludeKey = `includeIf.gitdir:${containerGitDir}.path` await this.git.config(containerIncludeKey, containerCredentialsPath) - this.credentialsIncludeKeys.push(containerIncludeKey) } } - 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(this.tokenPlaceholderConfigValue) - if ( - placeholderIndex < 0 || - placeholderIndex != content.lastIndexOf(this.tokenPlaceholderConfigValue) - ) { - throw new Error(`Unable to replace auth placeholder in ${configPath}`) + /** + * Gets or creates the path to the credentials config file in RUNNER_TEMP. + * @returns The absolute path to the credentials config file + */ + private async getCredentialsConfigPath(): Promise { + if (this.credentialsConfigPath) { + return this.credentialsConfigPath } - assert.ok(this.tokenConfigValue, 'tokenConfigValue is not defined') - content = content.replace( - this.tokenPlaceholderConfigValue, - this.tokenConfigValue - ) - await fs.promises.writeFile(configPath, content) + + const runnerTemp = process.env['RUNNER_TEMP'] || '' + assert.ok(runnerTemp, 'RUNNER_TEMP is not defined') + + // Create a unique filename for this checkout instance + const configFileName = `git-credentials-${uuid()}.config` + this.credentialsConfigPath = path.join(runnerTemp, configFileName) + + core.debug(`Credentials config path: ${this.credentialsConfigPath}`) + return this.credentialsConfigPath } + /** + * Removes SSH authentication configuration by cleaning up SSH keys, + * known hosts files, and SSH command configurations. + */ private async removeSsh(): Promise { // SSH key const keyPath = this.sshKeyPath || stateHelper.SshKeyPath @@ -443,40 +446,23 @@ class GitAuthHelper { await this.removeSubmoduleGitConfig(SSH_COMMAND_KEY) } + /** + * Removes token-based authentication by cleaning up HTTP headers, + * includeIf entries, and credentials config files. + */ private async removeToken(): Promise { // Remove HTTP extra header await this.removeGitConfig(this.tokenConfigKey) await this.removeSubmoduleGitConfig(this.tokenConfigKey) // Remove includeIf entries that point to git-credentials-*.config files - // This is more aggressive than tracking keys, but necessary since cleanup - // runs in a post-step where this.credentialsIncludeKeys is empty - try { - // Get all includeIf.gitdir keys - const keys = await this.git.tryGetConfigKeys('^includeIf\\.gitdir:') - - for (const key of keys) { - // Get all values for this key - const values = await this.git.tryGetConfigValues(key) - if (values.length > 0) { - // Remove only values that match git-credentials-.config pattern - for (const value of values) { - if (/git-credentials-[0-9a-f-]+\.config$/i.test(value)) { - await this.git.tryConfigUnsetValue(key, value) - } - } - } - } - } catch (err) { - // Ignore errors - this is cleanup code - core.debug(`Error during includeIf cleanup: ${err}`) - } + await this.removeIncludeIfCredentials() - // Remove submodule includeIf - await this.git.submoduleForeach( - `sh -c "git config --local --get-regexp '^includeif\\.' && git config --local --remove-section includeif || :"`, - true - ) + // Remove submodule includeIf entries that point to git-credentials-*.config files + const submoduleConfigPaths = await this.git.getSubmoduleConfigPaths(true) + for (const configPath of submoduleConfigPaths) { + await this.removeIncludeIfCredentials(configPath) + } // Remove credentials config file if (this.credentialsConfigPath) { @@ -491,6 +477,10 @@ class GitAuthHelper { } } + /** + * Removes a git config key from the local repository config. + * @param configKey The git config key to remove + */ private async removeGitConfig(configKey: string): Promise { if ( (await this.git.configExists(configKey)) && @@ -501,6 +491,10 @@ class GitAuthHelper { } } + /** + * Removes a git config key from all submodule configs. + * @param configKey The git config key to remove + */ private async removeSubmoduleGitConfig(configKey: string): Promise { const pattern = regexpHelper.escape(configKey) await this.git.submoduleForeach( @@ -509,4 +503,44 @@ class GitAuthHelper { true ) } + + /** + * Removes includeIf entries that point to git-credentials-*.config files. + * @param configPath Optional path to a specific git config file to operate on + */ + private async removeIncludeIfCredentials(configPath?: string): Promise { + try { + // Get all includeIf.gitdir keys + const keys = await this.git.tryGetConfigKeys('^includeIf\\.gitdir:', false, configPath) + + for (const key of keys) { + // Get all values for this key + const values = await this.git.tryGetConfigValues(key, false, configPath) + if (values.length > 0) { + // Remove only values that match git-credentials-.config pattern + for (const value of values) { + if (this.testCredentialsConfigPath(value)) { + await this.git.tryConfigUnsetValue(key, value, false, configPath) + } + } + } + } + } catch (err) { + // Ignore errors - this is cleanup code + if (configPath) { + core.debug(`Error during includeIf cleanup for ${configPath}: ${err}`) + } else { + core.debug(`Error during includeIf cleanup: ${err}`) + } + } + } + + /** + * Tests if a path matches the git-credentials-*.config pattern. + * @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 ed3220b..71a5e9e 100644 --- a/src/git-command-manager.ts +++ b/src/git-command-manager.ts @@ -42,6 +42,7 @@ export interface IGitCommandManager { } ): Promise getDefaultBranch(repositoryUrl: string): Promise + getSubmoduleConfigPaths(recursive: boolean): Promise getWorkingDirectory(): string init(): Promise isDetached(): Promise @@ -60,11 +61,11 @@ export interface IGitCommandManager { tagExists(pattern: string): Promise tryClean(): Promise tryConfigUnset(configKey: string, globalConfig?: boolean): Promise - tryConfigUnsetValue(configKey: string, configValue: string, globalConfig?: boolean): Promise + tryConfigUnsetValue(configKey: string, configValue: string, globalConfig?: boolean, configFile?: string): Promise tryDisableAutomaticGarbageCollection(): Promise tryGetFetchUrl(): Promise - tryGetConfigValues(configKey: string, globalConfig?: boolean): Promise - tryGetConfigKeys(pattern: string, globalConfig?: boolean): Promise + tryGetConfigValues(configKey: string, globalConfig?: boolean, configFile?: string): Promise + tryGetConfigKeys(pattern: string, globalConfig?: boolean, configFile?: string): Promise tryReset(): Promise version(): Promise } @@ -333,6 +334,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 } @@ -468,18 +484,18 @@ class GitCommandManager { async tryConfigUnsetValue( configKey: string, configValue: string, - globalConfig?: boolean + globalConfig?: boolean, + configFile?: string ): Promise { - const output = await this.execGit( - [ - 'config', - globalConfig ? '--global' : '--local', - '--unset', - configKey, - configValue - ], - true - ) + 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 } @@ -511,17 +527,18 @@ class GitCommandManager { async tryGetConfigValues( configKey: string, - globalConfig?: boolean + globalConfig?: boolean, + configFile?: string ): Promise { - const output = await this.execGit( - [ - 'config', - globalConfig ? '--global' : '--local', - '--get-all', - configKey - ], - true - ) + 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 [] @@ -532,18 +549,18 @@ class GitCommandManager { async tryGetConfigKeys( pattern: string, - globalConfig?: boolean + globalConfig?: boolean, + configFile?: string ): Promise { - const output = await this.execGit( - [ - 'config', - globalConfig ? '--global' : '--local', - '--name-only', - '--get-regexp', - pattern - ], - true - ) + 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 []