Prechádzať zdrojové kódy

Persist creds to a separate file (#2286)

eric sciple 7 mesiacov pred
rodič
commit
069c695914

+ 4 - 5
.github/workflows/test.yml

@@ -302,12 +302,15 @@ jobs:
       # Clone this repo
       - name: Checkout
         uses: actions/checkout@v4.1.6
+        with:
+          path: actions-checkout
 
       # Basic checkout using git
       - name: Checkout basic
         id: checkout
-        uses: ./
+        uses: ./actions-checkout
         with:
+          path: cloned-using-local-action
           ref: test-data/v2/basic
 
       # Verify output
@@ -325,7 +328,3 @@ jobs:
             echo "Expected commit to be 82f71901cf8c021332310dcc8cdba84c4193ff5d"
             exit 1
           fi
-
-      # needed to make checkout post cleanup succeed
-      - name: Fix Checkout
-        uses: actions/checkout@v4.1.6

+ 380 - 29
__test__/git-auth-helper.test.ts

@@ -86,16 +86,29 @@ describe('git-auth-helper tests', () => {
     // Act
     await authHelper.configureAuth()
 
-    // Assert config
-    const configContent = (
+    // Assert config - check that .git/config contains includeIf entries
+    const localConfigContent = (
       await fs.promises.readFile(localGitConfigPath)
     ).toString()
+    expect(
+      localConfigContent.indexOf('includeIf.gitdir:')
+    ).toBeGreaterThanOrEqual(0)
+
+    // Assert credentials config file contains the actual credentials
+    const credentialsFiles = (await fs.promises.readdir(runnerTemp)).filter(
+      f => f.startsWith('git-credentials-') && f.endsWith('.config')
+    )
+    expect(credentialsFiles.length).toBe(1)
+    const credentialsConfigPath = path.join(runnerTemp, credentialsFiles[0])
+    const credentialsContent = (
+      await fs.promises.readFile(credentialsConfigPath)
+    ).toString()
     const basicCredential = Buffer.from(
       `x-access-token:${settings.authToken}`,
       'utf8'
     ).toString('base64')
     expect(
-      configContent.indexOf(
+      credentialsContent.indexOf(
         `http.${expectedServerUrl}/.extraheader AUTHORIZATION: basic ${basicCredential}`
       )
     ).toBeGreaterThanOrEqual(0)
@@ -120,7 +133,7 @@ describe('git-auth-helper tests', () => {
     'inject https://github.com as github server url'
   it(configureAuth_AcceptsGitHubServerUrlSetToGHEC, async () => {
     await testAuthHeader(
-      configureAuth_AcceptsGitHubServerUrl,
+      configureAuth_AcceptsGitHubServerUrlSetToGHEC,
       'https://github.com'
     )
   })
@@ -141,12 +154,17 @@ describe('git-auth-helper tests', () => {
       // Act
       await authHelper.configureAuth()
 
-      // Assert config
-      const configContent = (
-        await fs.promises.readFile(localGitConfigPath)
+      // Assert config - check credentials config file (not local .git/config)
+      const credentialsFiles = (await fs.promises.readdir(runnerTemp)).filter(
+        f => f.startsWith('git-credentials-') && f.endsWith('.config')
+      )
+      expect(credentialsFiles.length).toBe(1)
+      const credentialsConfigPath = path.join(runnerTemp, credentialsFiles[0])
+      const credentialsContent = (
+        await fs.promises.readFile(credentialsConfigPath)
       ).toString()
       expect(
-        configContent.indexOf(
+        credentialsContent.indexOf(
           `http.https://github.com/.extraheader AUTHORIZATION`
         )
       ).toBeGreaterThanOrEqual(0)
@@ -251,13 +269,16 @@ describe('git-auth-helper tests', () => {
       expectedSshCommand
     )
 
-    // Asserty git config
+    // Assert git config
     const gitConfigLines = (await fs.promises.readFile(localGitConfigPath))
       .toString()
       .split('\n')
       .filter(x => x)
-    expect(gitConfigLines).toHaveLength(1)
-    expect(gitConfigLines[0]).toMatch(/^http\./)
+    // Should have includeIf entries pointing to credentials file
+    expect(gitConfigLines.length).toBeGreaterThan(0)
+    expect(
+      gitConfigLines.some(line => line.indexOf('includeIf.gitdir:') >= 0)
+    ).toBeTruthy()
   })
 
   const configureAuth_setsSshCommandWhenPersistCredentialsTrue =
@@ -419,8 +440,20 @@ describe('git-auth-helper tests', () => {
     expect(
       configContent.indexOf('value-from-global-config')
     ).toBeGreaterThanOrEqual(0)
+    // Global config should have include.path pointing to credentials file
+    expect(configContent.indexOf('include.path')).toBeGreaterThanOrEqual(0)
+
+    // Check credentials in the separate config file
+    const credentialsFiles = (await fs.promises.readdir(runnerTemp)).filter(
+      f => f.startsWith('git-credentials-') && f.endsWith('.config')
+    )
+    expect(credentialsFiles.length).toBeGreaterThan(0)
+    const credentialsConfigPath = path.join(runnerTemp, credentialsFiles[0])
+    const credentialsContent = (
+      await fs.promises.readFile(credentialsConfigPath)
+    ).toString()
     expect(
-      configContent.indexOf(
+      credentialsContent.indexOf(
         `http.https://github.com/.extraheader AUTHORIZATION: basic ${basicCredential}`
       )
     ).toBeGreaterThanOrEqual(0)
@@ -463,8 +496,20 @@ describe('git-auth-helper tests', () => {
       const configContent = (
         await fs.promises.readFile(path.join(git.env['HOME'], '.gitconfig'))
       ).toString()
+      // Global config should have include.path pointing to credentials file
+      expect(configContent.indexOf('include.path')).toBeGreaterThanOrEqual(0)
+
+      // Check credentials in the separate config file
+      const credentialsFiles = (await fs.promises.readdir(runnerTemp)).filter(
+        f => f.startsWith('git-credentials-') && f.endsWith('.config')
+      )
+      expect(credentialsFiles.length).toBeGreaterThan(0)
+      const credentialsConfigPath = path.join(runnerTemp, credentialsFiles[0])
+      const credentialsContent = (
+        await fs.promises.readFile(credentialsConfigPath)
+      ).toString()
       expect(
-        configContent.indexOf(
+        credentialsContent.indexOf(
           `http.https://github.com/.extraheader AUTHORIZATION: basic ${basicCredential}`
         )
       ).toBeGreaterThanOrEqual(0)
@@ -550,15 +595,15 @@ describe('git-auth-helper tests', () => {
       await authHelper.configureSubmoduleAuth()
 
       // Assert
-      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(/http.*extraheader/)
-      expect(mockSubmoduleForeach.mock.calls[2][0]).toMatch(
+      expect(mockSubmoduleForeach.mock.calls[1][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:/
       )
     }
@@ -589,12 +634,12 @@ describe('git-auth-helper tests', () => {
       await authHelper.configureSubmoduleAuth()
 
       // Assert
-      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(/http.*extraheader/)
-      expect(mockSubmoduleForeach.mock.calls[2][0]).toMatch(/core\.sshCommand/)
+      expect(mockSubmoduleForeach.mock.calls[1][0]).toMatch(/core\.sshCommand/)
     }
   )
 
@@ -660,19 +705,201 @@ describe('git-auth-helper tests', () => {
     await setup(removeAuth_removesToken)
     const authHelper = gitAuthHelper.createAuthHelper(git, settings)
     await authHelper.configureAuth()
-    let gitConfigContent = (
+
+    // Verify includeIf entries exist in local config
+    let localConfigContent = (
       await fs.promises.readFile(localGitConfigPath)
     ).toString()
-    expect(gitConfigContent.indexOf('http.')).toBeGreaterThanOrEqual(0) // sanity check
+    expect(
+      localConfigContent.indexOf('includeIf.gitdir:')
+    ).toBeGreaterThanOrEqual(0)
+
+    // Verify both host and container includeIf entries are present
+    const hostGitDir = path.join(workspace, '.git').replace(/\\/g, '/')
+    expect(
+      localConfigContent.indexOf(`includeIf.gitdir:${hostGitDir}.path`)
+    ).toBeGreaterThanOrEqual(0)
+    expect(
+      localConfigContent.indexOf('includeIf.gitdir:/github/workspace/.git.path')
+    ).toBeGreaterThanOrEqual(0)
+
+    // Verify credentials file exists
+    let credentialsFiles = (await fs.promises.readdir(runnerTemp)).filter(
+      f => f.startsWith('git-credentials-') && f.endsWith('.config')
+    )
+    expect(credentialsFiles.length).toBe(1)
+    const credentialsFilePath = path.join(runnerTemp, credentialsFiles[0])
+
+    // Verify credentials file contains the auth token
+    let credentialsContent = (
+      await fs.promises.readFile(credentialsFilePath)
+    ).toString()
+    const basicCredential = Buffer.from(
+      `x-access-token:${settings.authToken}`,
+      'utf8'
+    ).toString('base64')
+    expect(
+      credentialsContent.indexOf(
+        `http.https://github.com/.extraheader AUTHORIZATION: basic ${basicCredential}`
+      )
+    ).toBeGreaterThanOrEqual(0)
+
+    // Verify the includeIf entries point to the credentials file
+    const containerCredentialsPath = path.posix.join(
+      '/github/runner_temp',
+      path.basename(credentialsFilePath)
+    )
+    expect(
+      localConfigContent.indexOf(credentialsFilePath)
+    ).toBeGreaterThanOrEqual(0)
+    expect(
+      localConfigContent.indexOf(containerCredentialsPath)
+    ).toBeGreaterThanOrEqual(0)
 
     // Act
     await authHelper.removeAuth()
 
-    // Assert git config
-    gitConfigContent = (
+    // Assert all includeIf entries removed from local git config
+    localConfigContent = (
       await fs.promises.readFile(localGitConfigPath)
     ).toString()
-    expect(gitConfigContent.indexOf('http.')).toBeLessThan(0)
+    expect(localConfigContent.indexOf('includeIf.gitdir:')).toBeLessThan(0)
+    expect(
+      localConfigContent.indexOf(`includeIf.gitdir:${hostGitDir}.path`)
+    ).toBeLessThan(0)
+    expect(
+      localConfigContent.indexOf('includeIf.gitdir:/github/workspace/.git.path')
+    ).toBeLessThan(0)
+    expect(localConfigContent.indexOf(credentialsFilePath)).toBeLessThan(0)
+    expect(localConfigContent.indexOf(containerCredentialsPath)).toBeLessThan(0)
+
+    // Assert credentials config file deleted
+    credentialsFiles = (await fs.promises.readdir(runnerTemp)).filter(
+      f => f.startsWith('git-credentials-') && f.endsWith('.config')
+    )
+    expect(credentialsFiles.length).toBe(0)
+
+    // Verify credentials file no longer exists on disk
+    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_removesTokenFromSubmodules =
+    'removeAuth removes token from submodules'
+  it(removeAuth_removesTokenFromSubmodules, async () => {
+    // Arrange
+    await setup(removeAuth_removesTokenFromSubmodules)
+
+    // Create fake submodule config paths
+    const submodule1Dir = path.join(workspace, '.git', 'modules', 'submodule-1')
+    const submodule2Dir = path.join(workspace, '.git', 'modules', 'submodule-2')
+    const submodule1ConfigPath = path.join(submodule1Dir, 'config')
+    const submodule2ConfigPath = path.join(submodule2Dir, 'config')
+
+    await fs.promises.mkdir(submodule1Dir, {recursive: true})
+    await fs.promises.mkdir(submodule2Dir, {recursive: true})
+    await fs.promises.writeFile(submodule1ConfigPath, '')
+    await fs.promises.writeFile(submodule2ConfigPath, '')
+
+    // Mock getSubmoduleConfigPaths to return our fake submodules (for both configure and remove)
+    const mockGetSubmoduleConfigPaths =
+      git.getSubmoduleConfigPaths as jest.Mock<any, any>
+    mockGetSubmoduleConfigPaths.mockResolvedValue([
+      submodule1ConfigPath,
+      submodule2ConfigPath
+    ])
+
+    const authHelper = gitAuthHelper.createAuthHelper(git, settings)
+    await authHelper.configureAuth()
+    await authHelper.configureSubmoduleAuth()
+
+    // Verify credentials file exists
+    let credentialsFiles = (await fs.promises.readdir(runnerTemp)).filter(
+      f => f.startsWith('git-credentials-') && f.endsWith('.config')
+    )
+    expect(credentialsFiles.length).toBe(1)
+    const credentialsFilePath = path.join(runnerTemp, credentialsFiles[0])
+
+    // Verify submodule 1 config has includeIf entries
+    let submodule1Content = (
+      await fs.promises.readFile(submodule1ConfigPath)
+    ).toString()
+    const submodule1GitDir = submodule1Dir.replace(/\\/g, '/')
+    expect(
+      submodule1Content.indexOf(`includeIf.gitdir:${submodule1GitDir}.path`)
+    ).toBeGreaterThanOrEqual(0)
+    expect(
+      submodule1Content.indexOf(credentialsFilePath)
+    ).toBeGreaterThanOrEqual(0)
+
+    // Verify submodule 2 config has includeIf entries
+    let submodule2Content = (
+      await fs.promises.readFile(submodule2ConfigPath)
+    ).toString()
+    const submodule2GitDir = submodule2Dir.replace(/\\/g, '/')
+    expect(
+      submodule2Content.indexOf(`includeIf.gitdir:${submodule2GitDir}.path`)
+    ).toBeGreaterThanOrEqual(0)
+    expect(
+      submodule2Content.indexOf(credentialsFilePath)
+    ).toBeGreaterThanOrEqual(0)
+
+    // Verify both host and container paths are in each submodule config
+    const containerCredentialsPath = path.posix.join(
+      '/github/runner_temp',
+      path.basename(credentialsFilePath)
+    )
+    expect(
+      submodule1Content.indexOf(containerCredentialsPath)
+    ).toBeGreaterThanOrEqual(0)
+    expect(
+      submodule2Content.indexOf(containerCredentialsPath)
+    ).toBeGreaterThanOrEqual(0)
+
+    // Act - ensure mock persists for removeAuth
+    mockGetSubmoduleConfigPaths.mockResolvedValue([
+      submodule1ConfigPath,
+      submodule2ConfigPath
+    ])
+    await authHelper.removeAuth()
+
+    // Assert submodule 1 includeIf entries removed
+    submodule1Content = (
+      await fs.promises.readFile(submodule1ConfigPath)
+    ).toString()
+    expect(submodule1Content.indexOf('includeIf.gitdir:')).toBeLessThan(0)
+    expect(submodule1Content.indexOf(credentialsFilePath)).toBeLessThan(0)
+    expect(submodule1Content.indexOf(containerCredentialsPath)).toBeLessThan(0)
+
+    // Assert submodule 2 includeIf entries removed
+    submodule2Content = (
+      await fs.promises.readFile(submodule2ConfigPath)
+    ).toString()
+    expect(submodule2Content.indexOf('includeIf.gitdir:')).toBeLessThan(0)
+    expect(submodule2Content.indexOf(credentialsFilePath)).toBeLessThan(0)
+    expect(submodule2Content.indexOf(containerCredentialsPath)).toBeLessThan(0)
+
+    // Assert credentials config file deleted
+    credentialsFiles = (await fs.promises.readdir(runnerTemp)).filter(
+      f => f.startsWith('git-credentials-') && f.endsWith('.config')
+    )
+    expect(credentialsFiles.length).toBe(0)
+
+    // Verify credentials file no longer exists on disk
+    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 removeGlobalConfig_removesOverride =
@@ -701,6 +928,52 @@ 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<void> {
@@ -715,6 +988,7 @@ async function setup(testName: string): Promise<void> {
   await fs.promises.mkdir(tempHomedir, {recursive: true})
   process.env['RUNNER_TEMP'] = runnerTemp
   process.env['HOME'] = tempHomedir
+  process.env['GITHUB_WORKSPACE'] = workspace
 
   // Create git config
   globalGitConfigPath = path.join(tempHomedir, '.gitconfig')
@@ -733,10 +1007,20 @@ async function setup(testName: string): Promise<void> {
     checkout: jest.fn(),
     checkoutDetach: jest.fn(),
     config: jest.fn(
-      async (key: string, value: string, globalConfig?: boolean) => {
-        const configPath = globalConfig
-          ? path.join(git.env['HOME'] || tempHomedir, '.gitconfig')
-          : localGitConfigPath
+      async (
+        key: string,
+        value: string,
+        globalConfig?: boolean,
+        add?: boolean,
+        configFile?: string
+      ) => {
+        const configPath =
+          configFile ||
+          (globalConfig
+            ? path.join(git.env['HOME'] || tempHomedir, '.gitconfig')
+            : localGitConfigPath)
+        // Ensure directory exists
+        await fs.promises.mkdir(path.dirname(configPath), {recursive: true})
         await fs.promises.appendFile(configPath, `\n${key} ${value}`)
       }
     ),
@@ -756,6 +1040,7 @@ async function setup(testName: string): Promise<void> {
     env: {},
     fetch: jest.fn(),
     getDefaultBranch: jest.fn(),
+    getSubmoduleConfigPaths: jest.fn(async () => []),
     getWorkingDirectory: jest.fn(() => workspace),
     init: jest.fn(),
     isDetached: jest.fn(),
@@ -794,8 +1079,72 @@ async function setup(testName: string): Promise<void> {
         return true
       }
     ),
+    tryConfigUnsetValue: jest.fn(
+      async (
+        key: string,
+        value: string,
+        globalConfig?: boolean,
+        configPath?: string
+      ): Promise<boolean> => {
+        const targetConfigPath =
+          configPath ||
+          (globalConfig
+            ? path.join(git.env['HOME'] || tempHomedir, '.gitconfig')
+            : localGitConfigPath)
+        let content = await fs.promises.readFile(targetConfigPath)
+        let lines = content
+          .toString()
+          .split('\n')
+          .filter(x => x)
+          .filter(x => !(x.startsWith(key) && x.includes(value)))
+        await fs.promises.writeFile(targetConfigPath, lines.join('\n'))
+        return true
+      }
+    ),
     tryDisableAutomaticGarbageCollection: jest.fn(),
     tryGetFetchUrl: jest.fn(),
+    tryGetConfigValues: jest.fn(
+      async (
+        key: string,
+        globalConfig?: boolean,
+        configPath?: string
+      ): Promise<string[]> => {
+        const targetConfigPath =
+          configPath ||
+          (globalConfig
+            ? path.join(git.env['HOME'] || tempHomedir, '.gitconfig')
+            : localGitConfigPath)
+        const content = await fs.promises.readFile(targetConfigPath)
+        const lines = content
+          .toString()
+          .split('\n')
+          .filter(x => x && x.startsWith(key))
+          .map(x => x.substring(key.length).trim())
+        return lines
+      }
+    ),
+    tryGetConfigKeys: jest.fn(
+      async (
+        pattern: string,
+        globalConfig?: boolean,
+        configPath?: string
+      ): Promise<string[]> => {
+        const targetConfigPath =
+          configPath ||
+          (globalConfig
+            ? path.join(git.env['HOME'] || tempHomedir, '.gitconfig')
+            : localGitConfigPath)
+        const content = await fs.promises.readFile(targetConfigPath)
+        const lines = content
+          .toString()
+          .split('\n')
+          .filter(x => x)
+        const keys = lines
+          .filter(x => new RegExp(pattern).test(x.split(' ')[0]))
+          .map(x => x.split(' ')[0])
+        return [...new Set(keys)] // Remove duplicates
+      }
+    ),
     tryReset: jest.fn(),
     version: jest.fn()
   }
@@ -830,6 +1179,7 @@ async function setup(testName: string): Promise<void> {
 
 async function getActualSshKeyPath(): Promise<string> {
   let actualTempFiles = (await fs.promises.readdir(runnerTemp))
+    .filter(x => !x.startsWith('git-credentials-')) // Exclude credentials config file
     .sort()
     .map(x => path.join(runnerTemp, x))
   if (actualTempFiles.length === 0) {
@@ -843,6 +1193,7 @@ async function getActualSshKeyPath(): Promise<string> {
 
 async function getActualSshKnownHostsPath(): Promise<string> {
   let actualTempFiles = (await fs.promises.readdir(runnerTemp))
+    .filter(x => !x.startsWith('git-credentials-')) // Exclude credentials config file
     .sort()
     .map(x => path.join(runnerTemp, x))
   if (actualTempFiles.length === 0) {

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

@@ -471,6 +471,7 @@ async function setup(testName: string): Promise<void> {
     configExists: jest.fn(),
     fetch: jest.fn(),
     getDefaultBranch: jest.fn(),
+    getSubmoduleConfigPaths: jest.fn(async () => []),
     getWorkingDirectory: jest.fn(() => repositoryPath),
     init: jest.fn(),
     isDetached: jest.fn(),
@@ -493,12 +494,15 @@ async function setup(testName: string): Promise<void> {
       return true
     }),
     tryConfigUnset: jest.fn(),
+    tryConfigUnsetValue: jest.fn(),
     tryDisableAutomaticGarbageCollection: jest.fn(),
     tryGetFetchUrl: jest.fn(async () => {
       // Sanity check - this function shouldn't be called when the .git directory doesn't exist
       await fs.promises.stat(path.join(repositoryPath, '.git'))
       return repositoryUrl
     }),
+    tryGetConfigValues: jest.fn(),
+    tryGetConfigKeys: jest.fn(),
     tryReset: jest.fn(async () => {
       return true
     }),

+ 1 - 1
__test__/verify-submodules-recursive.sh

@@ -17,7 +17,7 @@ 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
+git config --local --includes --name-only --get-regexp http.+extraheader && git fetch
 if [ "$?" != "0" ]; then
     echo "Failed to validate persisted credential"
     popd

+ 1 - 1
__test__/verify-submodules-true.sh

@@ -17,7 +17,7 @@ fi
 
 echo "Testing persisted credential"
 pushd ./submodules-true/submodule-level-1
-git config --local --name-only --get-regexp http.+extraheader && git fetch
+git config --local --includes --name-only --get-regexp http.+extraheader && git fetch
 if [ "$?" != "0" ]; then
     echo "Failed to validate persisted credential"
     popd

+ 286 - 50
dist/index.js

@@ -162,6 +162,7 @@ class GitAuthHelper {
         this.sshKeyPath = '';
         this.sshKnownHostsPath = '';
         this.temporaryHomePath = '';
+        this.credentialsConfigPath = ''; // Path to separate credentials config file in RUNNER_TEMP
         this.git = gitCommandManager;
         this.settings = gitSourceSettings || {};
         // Token auth header
@@ -229,15 +230,17 @@ class GitAuthHelper {
     configureGlobalAuth() {
         return __awaiter(this, void 0, void 0, function* () {
             // 'configureTempGlobalConfig' noops if already set, just returns the path
-            const newGitConfigPath = yield this.configureTempGlobalConfig();
+            yield this.configureTempGlobalConfig();
             try {
                 // Configure the token
-                yield this.configureToken(newGitConfigPath, true);
+                yield this.configureToken(true);
                 // Configure HTTPS instead of SSH
                 yield this.git.tryConfigUnset(this.insteadOfKey, true);
                 if (!this.settings.sshKey) {
                     for (const insteadOfValue of this.insteadOfValues) {
-                        yield this.git.config(this.insteadOfKey, insteadOfValue, true, true);
+                        yield this.git.config(this.insteadOfKey, insteadOfValue, true, // globalConfig?
+                        true // add?
+                        );
                     }
                 }
             }
@@ -252,19 +255,34 @@ class GitAuthHelper {
     configureSubmoduleAuth() {
         return __awaiter(this, void 0, void 0, function* () {
             // Remove possible previous HTTPS instead of SSH
-            yield this.removeGitConfig(this.insteadOfKey, true);
+            yield this.removeSubmoduleGitConfig(this.insteadOfKey);
             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(
-                // wrap the pipeline in quotes to make sure it's handled properly by submoduleForeach, rather than just the first part of the pipeline
-                `sh -c "git config --local '${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) || [];
+                // Get the credentials config file path in RUNNER_TEMP
+                const credentialsConfigPath = this.getCredentialsConfigPath();
+                // Container credentials config path
+                const containerCredentialsPath = path.posix.join('/github/runner_temp', path.basename(credentialsConfigPath));
+                // Get submodule config file paths.
+                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) {
-                    core.debug(`Replacing token placeholder in '${configPath}'`);
-                    yield this.replaceTokenPlaceholder(configPath);
+                    // Submodule Git directory
+                    let submoduleGitDir = path.dirname(configPath); // The config file is at .git/modules/submodule-name/config
+                    submoduleGitDir = submoduleGitDir.replace(/\\/g, '/'); // Use forward slashes, even on Windows
+                    // Configure host includeIf
+                    yield this.git.config(`includeIf.gitdir:${submoduleGitDir}.path`, credentialsConfigPath, false, // globalConfig?
+                    false, // add?
+                    configPath);
+                    // Container submodule git directory
+                    const githubWorkspace = process.env['GITHUB_WORKSPACE'];
+                    assert.ok(githubWorkspace, 'GITHUB_WORKSPACE is not defined');
+                    let relativeSubmoduleGitDir = path.relative(githubWorkspace, submoduleGitDir);
+                    relativeSubmoduleGitDir = relativeSubmoduleGitDir.replace(/\\/g, '/'); // Use forward slashes, even on Windows
+                    const containerSubmoduleGitDir = path.posix.join('/github/workspace', relativeSubmoduleGitDir);
+                    // Configure container includeIf
+                    yield this.git.config(`includeIf.gitdir:${containerSubmoduleGitDir}.path`, containerCredentialsPath, false, // globalConfig?
+                    false, // add?
+                    configPath);
                 }
                 if (this.settings.sshKey) {
                     // Configure core.sshCommand
@@ -295,6 +313,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) {
@@ -351,43 +373,88 @@ class GitAuthHelper {
             }
         });
     }
-    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
-            yield this.git.config(this.tokenConfigKey, this.tokenPlaceholderConfigValue, globalConfig);
-            // Replace the placeholder
-            yield this.replaceTokenPlaceholder(configPath || '');
-        });
-    }
-    replaceTokenPlaceholder(configPath) {
+    /**
+     * 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* () {
-            assert.ok(configPath, 'configPath is not defined');
-            let content = (yield fs.promises.readFile(configPath)).toString();
+            // Get the credentials config file path in RUNNER_TEMP
+            const credentialsConfigPath = this.getCredentialsConfigPath();
+            // Write placeholder to the separate credentials config file using git config.
+            // 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
+            yield this.git.config(this.tokenConfigKey, this.tokenPlaceholderConfigValue, false, // globalConfig?
+            false, // add?
+            credentialsConfigPath);
+            // Replace the placeholder in the credentials config file
+            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 ${configPath}`);
+                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(configPath, content);
+            yield fs.promises.writeFile(credentialsConfigPath, content);
+            // Add include or includeIf to reference the credentials config
+            if (globalConfig) {
+                // Global config file is temporary
+                yield this.git.config('include.path', credentialsConfigPath, true // globalConfig?
+                );
+            }
+            else {
+                // Host git directory
+                let gitDir = path.join(this.git.getWorkingDirectory(), '.git');
+                gitDir = gitDir.replace(/\\/g, '/'); // Use forward slashes, even on Windows
+                // Configure host includeIf
+                const hostIncludeKey = `includeIf.gitdir:${gitDir}.path`;
+                yield this.git.config(hostIncludeKey, credentialsConfigPath);
+                // Container git directory
+                const workingDirectory = this.git.getWorkingDirectory();
+                const githubWorkspace = process.env['GITHUB_WORKSPACE'];
+                assert.ok(githubWorkspace, 'GITHUB_WORKSPACE is not defined');
+                let relativePath = path.relative(githubWorkspace, workingDirectory);
+                relativePath = relativePath.replace(/\\/g, '/'); // Use forward slashes, even on Windows
+                const containerGitDir = path.posix.join('/github/workspace', relativePath, '.git');
+                // Container credentials config path
+                const containerCredentialsPath = path.posix.join('/github/runner_temp', path.basename(credentialsConfigPath));
+                // Configure container includeIf
+                const containerIncludeKey = `includeIf.gitdir:${containerGitDir}.path`;
+                yield this.git.config(containerIncludeKey, containerCredentialsPath);
+            }
         });
     }
+    /**
+     * Gets or creates the path to the credentials config file in RUNNER_TEMP.
+     * @returns The absolute path to the credentials config file
+     */
+    getCredentialsConfigPath() {
+        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;
+    }
+    /**
+     * 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;
+            var _a, _b;
             // SSH key
             const keyPath = this.sshKeyPath || stateHelper.SshKeyPath;
             if (keyPath) {
                 try {
+                    core.info(`Removing SSH key '${keyPath}'`);
                     yield io.rmRF(keyPath);
                 }
                 catch (err) {
@@ -399,37 +466,136 @@ class GitAuthHelper {
             const knownHostsPath = this.sshKnownHostsPath || stateHelper.SshKnownHostsPath;
             if (knownHostsPath) {
                 try {
+                    core.info(`Removing SSH known hosts '${knownHostsPath}'`);
                     yield io.rmRF(knownHostsPath);
                 }
-                catch (_b) {
-                    // Intentionally empty
+                catch (err) {
+                    core.debug(`${(_b = err === null || err === void 0 ? void 0 : err.message) !== null && _b !== void 0 ? _b : err}`);
+                    core.warning(`Failed to remove SSH known hosts '${knownHostsPath}'`);
                 }
             }
             // SSH command
+            core.info('Removing SSH command configuration');
             yield this.removeGitConfig(SSH_COMMAND_KEY);
+            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* () {
-            // HTTP extra header
+            var _a;
+            // Remove HTTP extra header
+            core.info('Removing HTTP extra header');
             yield this.removeGitConfig(this.tokenConfigKey);
+            yield this.removeSubmoduleGitConfig(this.tokenConfigKey);
+            // Collect credentials config paths that need to be removed
+            const credentialsPaths = new Set();
+            // Remove includeIf entries that point to git-credentials-*.config files
+            core.info('Removing includeIf entries pointing to 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
+            const submoduleConfigPaths = yield this.git.getSubmoduleConfigPaths(true);
+            for (const configPath of submoduleConfigPaths) {
+                const submoduleCredentialsPaths = yield this.removeIncludeIfCredentials(configPath);
+                submoduleCredentialsPaths.forEach(path => credentialsPaths.add(path));
+            }
+            // 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'];
+                assert.ok(runnerTemp, 'RUNNER_TEMP is not defined');
+                if (credentialsPath.startsWith(runnerTemp)) {
+                    try {
+                        core.info(`Removing credentials config '${credentialsPath}'`);
+                        yield io.rmRF(credentialsPath);
+                    }
+                    catch (err) {
+                        core.debug(`${(_a = err === null || err === void 0 ? void 0 : err.message) !== null && _a !== void 0 ? _a : err}`);
+                        core.warning(`Failed to remove credentials config '${credentialsPath}'`);
+                    }
+                }
+                else {
+                    core.debug(`Skipping removal of credentials config '${credentialsPath}' - not under RUNNER_TEMP`);
+                }
+            }
         });
     }
-    removeGitConfig(configKey_1) {
-        return __awaiter(this, arguments, void 0, function* (configKey, submoduleOnly = false) {
-            if (!submoduleOnly) {
-                if ((yield this.git.configExists(configKey)) &&
-                    !(yield this.git.tryConfigUnset(configKey))) {
-                    // Load the config contents
-                    core.warning(`Failed to remove '${configKey}' from the git config`);
-                }
+    /**
+     * 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)) &&
+                !(yield this.git.tryConfigUnset(configKey))) {
+                // Load the config contents
+                core.warning(`Failed to remove '${configKey}' from the git config`);
             }
+        });
+    }
+    /**
+     * 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);
             yield this.git.submoduleForeach(
-            // wrap the pipeline in quotes to make sure it's handled properly by submoduleForeach, rather than just the first part of the pipeline
+            // Wrap the pipeline in quotes to make sure it's handled properly by submoduleForeach, rather than just the first part of the pipeline.
             `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
+     * @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
+                if (configPath) {
+                    core.debug(`Error during includeIf cleanup for ${configPath}: ${err}`);
+                }
+                else {
+                    core.debug(`Error during includeIf cleanup: ${err}`);
+                }
+            }
+            return Array.from(credentialsPaths);
+        });
+    }
+    /**
+     * 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);
+    }
 }
 
 
@@ -627,9 +793,15 @@ class GitCommandManager {
             yield this.execGit(args);
         });
     }
-    config(configKey, configValue, globalConfig, add) {
+    config(configKey, configValue, globalConfig, add, configFile) {
         return __awaiter(this, void 0, void 0, function* () {
-            const args = ['config', globalConfig ? '--global' : '--local'];
+            const args = ['config'];
+            if (configFile) {
+                args.push('--file', configFile);
+            }
+            else {
+                args.push(globalConfig ? '--global' : '--local');
+            }
             if (add) {
                 args.push('--add');
             }
@@ -706,6 +878,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 +1018,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 +1051,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);

+ 273 - 59
src/git-auth-helper.ts

@@ -43,6 +43,7 @@ class GitAuthHelper {
   private sshKeyPath = ''
   private sshKnownHostsPath = ''
   private temporaryHomePath = ''
+  private credentialsConfigPath = '' // Path to separate credentials config file in RUNNER_TEMP
 
   constructor(
     gitCommandManager: IGitCommandManager,
@@ -126,16 +127,21 @@ class GitAuthHelper {
 
   async configureGlobalAuth(): Promise<void> {
     // 'configureTempGlobalConfig' noops if already set, just returns the path
-    const newGitConfigPath = await this.configureTempGlobalConfig()
+    await this.configureTempGlobalConfig()
     try {
       // Configure the token
-      await this.configureToken(newGitConfigPath, true)
+      await this.configureToken(true)
 
       // Configure HTTPS instead of SSH
       await this.git.tryConfigUnset(this.insteadOfKey, true)
       if (!this.settings.sshKey) {
         for (const insteadOfValue of this.insteadOfValues) {
-          await this.git.config(this.insteadOfKey, insteadOfValue, true, true)
+          await this.git.config(
+            this.insteadOfKey,
+            insteadOfValue,
+            true, // globalConfig?
+            true // add?
+          )
         }
       }
     } catch (err) {
@@ -150,24 +156,60 @@ class GitAuthHelper {
 
   async configureSubmoduleAuth(): Promise<void> {
     // Remove possible previous HTTPS instead of SSH
-    await this.removeGitConfig(this.insteadOfKey, true)
+    await this.removeSubmoduleGitConfig(this.insteadOfKey)
 
     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(
-        // wrap the pipeline in quotes to make sure it's handled properly by submoduleForeach, rather than just the first part of the pipeline
-        `sh -c "git config --local '${this.tokenConfigKey}' '${this.tokenPlaceholderConfigValue}' && git config --local --show-origin --name-only --get-regexp remote.origin.url"`,
+      // Get the credentials config file path in RUNNER_TEMP
+      const credentialsConfigPath = this.getCredentialsConfigPath()
+
+      // Container credentials config path
+      const containerCredentialsPath = path.posix.join(
+        '/github/runner_temp',
+        path.basename(credentialsConfigPath)
+      )
+
+      // Get submodule config file paths.
+      const configPaths = await this.git.getSubmoduleConfigPaths(
         this.settings.nestedSubmodules
       )
 
-      // Replace the placeholder
-      const configPaths: string[] =
-        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) {
-        core.debug(`Replacing token placeholder in '${configPath}'`)
-        await this.replaceTokenPlaceholder(configPath)
+        // Submodule Git directory
+        let submoduleGitDir = path.dirname(configPath) // The config file is at .git/modules/submodule-name/config
+        submoduleGitDir = submoduleGitDir.replace(/\\/g, '/') // Use forward slashes, even on Windows
+
+        // Configure host includeIf
+        await this.git.config(
+          `includeIf.gitdir:${submoduleGitDir}.path`,
+          credentialsConfigPath,
+          false, // globalConfig?
+          false, // add?
+          configPath
+        )
+
+        // Container submodule git directory
+        const githubWorkspace = process.env['GITHUB_WORKSPACE']
+        assert.ok(githubWorkspace, 'GITHUB_WORKSPACE is not defined')
+        let relativeSubmoduleGitDir = path.relative(
+          githubWorkspace,
+          submoduleGitDir
+        )
+        relativeSubmoduleGitDir = relativeSubmoduleGitDir.replace(/\\/g, '/') // Use forward slashes, even on Windows
+        const containerSubmoduleGitDir = path.posix.join(
+          '/github/workspace',
+          relativeSubmoduleGitDir
+        )
+
+        // Configure container includeIf
+        await this.git.config(
+          `includeIf.gitdir:${containerSubmoduleGitDir}.path`,
+          containerCredentialsPath,
+          false, // globalConfig?
+          false, // add?
+          configPath
+        )
       }
 
       if (this.settings.sshKey) {
@@ -201,6 +243,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<void> {
     if (!this.settings.sshKey) {
       return
@@ -272,57 +318,116 @@ class GitAuthHelper {
     }
   }
 
-  private async configureToken(
-    configPath?: string,
-    globalConfig?: boolean
-  ): Promise<void> {
-    // 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
+  /**
+   * 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<void> {
+    // Get the credentials config file path in RUNNER_TEMP
+    const credentialsConfigPath = this.getCredentialsConfigPath()
+
+    // Write placeholder to the separate credentials config file using git config.
+    // 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
     await this.git.config(
       this.tokenConfigKey,
       this.tokenPlaceholderConfigValue,
-      globalConfig
+      false, // globalConfig?
+      false, // add?
+      credentialsConfigPath
     )
 
-    // Replace the placeholder
-    await this.replaceTokenPlaceholder(configPath || '')
-  }
-
-  private async replaceTokenPlaceholder(configPath: string): Promise<void> {
-    assert.ok(configPath, 'configPath is not defined')
-    let content = (await fs.promises.readFile(configPath)).toString()
+    // Replace the placeholder in the credentials config file
+    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 ${configPath}`)
+      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(configPath, content)
+    await fs.promises.writeFile(credentialsConfigPath, content)
+
+    // Add include or includeIf to reference the credentials config
+    if (globalConfig) {
+      // Global config file is temporary
+      await this.git.config(
+        'include.path',
+        credentialsConfigPath,
+        true // globalConfig?
+      )
+    } else {
+      // Host git directory
+      let gitDir = path.join(this.git.getWorkingDirectory(), '.git')
+      gitDir = gitDir.replace(/\\/g, '/') // Use forward slashes, even on Windows
+
+      // Configure host includeIf
+      const hostIncludeKey = `includeIf.gitdir:${gitDir}.path`
+      await this.git.config(hostIncludeKey, credentialsConfigPath)
+
+      // Container git directory
+      const workingDirectory = this.git.getWorkingDirectory()
+      const githubWorkspace = process.env['GITHUB_WORKSPACE']
+      assert.ok(githubWorkspace, 'GITHUB_WORKSPACE is not defined')
+      let relativePath = path.relative(githubWorkspace, workingDirectory)
+      relativePath = relativePath.replace(/\\/g, '/') // Use forward slashes, even on Windows
+      const containerGitDir = path.posix.join(
+        '/github/workspace',
+        relativePath,
+        '.git'
+      )
+
+      // Container credentials config path
+      const containerCredentialsPath = path.posix.join(
+        '/github/runner_temp',
+        path.basename(credentialsConfigPath)
+      )
+
+      // Configure container includeIf
+      const containerIncludeKey = `includeIf.gitdir:${containerGitDir}.path`
+      await this.git.config(containerIncludeKey, containerCredentialsPath)
+    }
+  }
+
+  /**
+   * Gets or creates the path to the credentials config file in RUNNER_TEMP.
+   * @returns The absolute path to the credentials config file
+   */
+  private getCredentialsConfigPath(): string {
+    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
   }
 
+  /**
+   * Removes SSH authentication configuration by cleaning up SSH keys,
+   * known hosts files, and SSH command configurations.
+   */
   private async removeSsh(): Promise<void> {
     // SSH key
     const keyPath = this.sshKeyPath || stateHelper.SshKeyPath
     if (keyPath) {
       try {
+        core.info(`Removing SSH key '${keyPath}'`)
         await io.rmRF(keyPath)
       } catch (err) {
         core.debug(`${(err as any)?.message ?? err}`)
@@ -335,40 +440,149 @@ class GitAuthHelper {
       this.sshKnownHostsPath || stateHelper.SshKnownHostsPath
     if (knownHostsPath) {
       try {
+        core.info(`Removing SSH known hosts '${knownHostsPath}'`)
         await io.rmRF(knownHostsPath)
-      } catch {
-        // Intentionally empty
+      } catch (err) {
+        core.debug(`${(err as any)?.message ?? err}`)
+        core.warning(`Failed to remove SSH known hosts '${knownHostsPath}'`)
       }
     }
 
     // SSH command
+    core.info('Removing SSH command configuration')
     await this.removeGitConfig(SSH_COMMAND_KEY)
+    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<void> {
-    // HTTP extra header
+    // Remove HTTP extra header
+    core.info('Removing HTTP extra header')
     await this.removeGitConfig(this.tokenConfigKey)
-  }
+    await this.removeSubmoduleGitConfig(this.tokenConfigKey)
+
+    // Collect credentials config paths that need to be removed
+    const credentialsPaths = new Set<string>()
+
+    // Remove includeIf entries that point to git-credentials-*.config files
+    core.info('Removing includeIf entries pointing to 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
+    const submoduleConfigPaths = await this.git.getSubmoduleConfigPaths(true)
+    for (const configPath of submoduleConfigPaths) {
+      const submoduleCredentialsPaths =
+        await this.removeIncludeIfCredentials(configPath)
+      submoduleCredentialsPaths.forEach(path => credentialsPaths.add(path))
+    }
 
-  private async removeGitConfig(
-    configKey: string,
-    submoduleOnly: boolean = false
-  ): Promise<void> {
-    if (!submoduleOnly) {
-      if (
-        (await this.git.configExists(configKey)) &&
-        !(await this.git.tryConfigUnset(configKey))
-      ) {
-        // Load the config contents
-        core.warning(`Failed to remove '${configKey}' from the git config`)
+    // 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']
+      assert.ok(runnerTemp, 'RUNNER_TEMP is not defined')
+      if (credentialsPath.startsWith(runnerTemp)) {
+        try {
+          core.info(`Removing credentials config '${credentialsPath}'`)
+          await io.rmRF(credentialsPath)
+        } catch (err) {
+          core.debug(`${(err as any)?.message ?? err}`)
+          core.warning(
+            `Failed to remove credentials config '${credentialsPath}'`
+          )
+        }
+      } else {
+        core.debug(
+          `Skipping removal of credentials config '${credentialsPath}' - not under RUNNER_TEMP`
+        )
       }
     }
+  }
+
+  /**
+   * Removes a git config key from the local repository config.
+   * @param configKey The git config key to remove
+   */
+  private async removeGitConfig(configKey: string): Promise<void> {
+    if (
+      (await this.git.configExists(configKey)) &&
+      !(await this.git.tryConfigUnset(configKey))
+    ) {
+      // Load the config contents
+      core.warning(`Failed to remove '${configKey}' from the git config`)
+    }
+  }
 
+  /**
+   * Removes a git config key from all submodule configs.
+   * @param configKey The git config key to remove
+   */
+  private async removeSubmoduleGitConfig(configKey: string): Promise<void> {
     const pattern = regexpHelper.escape(configKey)
     await this.git.submoduleForeach(
-      // wrap the pipeline in quotes to make sure it's handled properly by submoduleForeach, rather than just the first part of the pipeline
+      // Wrap the pipeline in quotes to make sure it's handled properly by submoduleForeach, rather than just the first part of the pipeline.
       `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
+   * @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
+      if (configPath) {
+        core.debug(`Error during includeIf cleanup for ${configPath}: ${err}`)
+      } else {
+        core.debug(`Error during includeIf cleanup: ${err}`)
+      }
+    }
+
+    return Array.from(credentialsPaths)
+  }
+
+  /**
+   * 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)
+  }
 }

+ 110 - 3
src/git-command-manager.ts

@@ -28,7 +28,8 @@ export interface IGitCommandManager {
     configKey: string,
     configValue: string,
     globalConfig?: boolean,
-    add?: boolean
+    add?: boolean,
+    configFile?: string
   ): Promise<void>
   configExists(configKey: string, globalConfig?: boolean): Promise<boolean>
   fetch(
@@ -41,6 +42,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 +61,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>
 }
@@ -223,9 +241,15 @@ class GitCommandManager {
     configKey: string,
     configValue: string,
     globalConfig?: boolean,
-    add?: boolean
+    add?: boolean,
+    configFile?: string
   ): Promise<void> {
-    const args: string[] = ['config', globalConfig ? '--global' : '--local']
+    const args: string[] = ['config']
+    if (configFile) {
+      args.push('--file', configFile)
+    } else {
+      args.push(globalConfig ? '--global' : '--local')
+    }
     if (add) {
       args.push('--add')
     }
@@ -323,6 +347,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 +494,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 +538,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