Ver código fonte

Cleanup actions/checkout@v6 auth style (#2301)

eric sciple 7 meses atrás
pai
commit
93cb6efe18

+ 289 - 0
__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<any, any>
+    mockTryGetConfigKeys.mockResolvedValue([
+      `includeIf.gitdir:${hostGitDir}/.path`,
+      'includeIf.gitdir:/github/workspace/.git/.path'
+    ])
+
+    const mockTryGetConfigValues = git.tryGetConfigValues as jest.Mock<any, any>
+    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<any, any>
+    mockGetSubmoduleConfigPaths.mockResolvedValue([submodule1ConfigPath])
+
+    // Mock tryGetConfigKeys for submodule
+    const mockTryGetConfigKeys = git.tryGetConfigKeys as jest.Mock<any, any>
+    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<any, any>
+    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<void> {
     ),
     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()
   }

+ 12 - 0
__test__/git-directory-helper.test.ts

@@ -499,6 +499,18 @@ async function setup(testName: string): Promise<void> {
       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
     }),

+ 150 - 1
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-<uuid>.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);

+ 106 - 1
src/git-auth-helper.ts

@@ -346,8 +346,58 @@ class GitAuthHelper {
   }
 
   private async removeToken(): Promise<void> {
-    // 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<string>()
+
+      // 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<string[]> {
+    const credentialsPaths = new Set<string>()
+
+    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-<uuid>.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)
+  }
 }

+ 100 - 0
src/git-command-manager.ts

@@ -41,6 +41,7 @@ export interface IGitCommandManager {
     }
   ): Promise<void>
   getDefaultBranch(repositoryUrl: string): Promise<string>
+  getSubmoduleConfigPaths(recursive: boolean): Promise<string[]>
   getWorkingDirectory(): string
   init(): Promise<void>
   isDetached(): Promise<boolean>
@@ -59,8 +60,24 @@ export interface IGitCommandManager {
   tagExists(pattern: string): Promise<boolean>
   tryClean(): Promise<boolean>
   tryConfigUnset(configKey: string, globalConfig?: boolean): Promise<boolean>
+  tryConfigUnsetValue(
+    configKey: string,
+    configValue: string,
+    globalConfig?: boolean,
+    configFile?: string
+  ): Promise<boolean>
   tryDisableAutomaticGarbageCollection(): Promise<boolean>
   tryGetFetchUrl(): Promise<string>
+  tryGetConfigValues(
+    configKey: string,
+    globalConfig?: boolean,
+    configFile?: string
+  ): Promise<string[]>
+  tryGetConfigKeys(
+    pattern: string,
+    globalConfig?: boolean,
+    configFile?: string
+  ): Promise<string[]>
   tryReset(): Promise<boolean>
   version(): Promise<GitVersion>
 }
@@ -323,6 +340,21 @@ class GitCommandManager {
     throw new Error('Unexpected output when retrieving default branch')
   }
 
+  async getSubmoduleConfigPaths(recursive: boolean): Promise<string[]> {
+    // 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<boolean> {
+    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<boolean> {
     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<string[]> {
+    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<string[]> {
+    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<boolean> {
     const output = await this.execGit(['reset', '--hard', 'HEAD'], true)
     return output.exitCode === 0