( commandArgs: string[], sourceDir: string, description: string, maxRetries: number = 5, )
| 116 | |
| 117 | // Retries git commands on lock conflicts with exponential backoff |
| 118 | export async function executeGitCommandWithRetry( |
| 119 | commandArgs: string[], |
| 120 | sourceDir: string, |
| 121 | description: string, |
| 122 | maxRetries: number = 5, |
| 123 | ): Promise<{ stdout: string; stderr: string }> { |
| 124 | await gitSemaphore.acquire(); |
| 125 | |
| 126 | try { |
| 127 | for (let attempt = 1; attempt <= maxRetries; attempt++) { |
| 128 | try { |
| 129 | const [cmd, ...args] = commandArgs; |
| 130 | const result = await $`cd ${sourceDir} && ${cmd} ${args}`; |
| 131 | return result; |
| 132 | } catch (error) { |
| 133 | const errMsg = error instanceof Error ? error.message : String(error); |
| 134 | |
| 135 | if (isGitLockError(errMsg) && attempt < maxRetries) { |
| 136 | const delay = 2 ** (attempt - 1) * 1000; |
| 137 | // executeGitCommandWithRetry is also called outside activity context |
| 138 | // (e.g., from resume logic), so we use console.warn as a fallback here |
| 139 | console.warn( |
| 140 | `Git lock conflict during ${description} (attempt ${attempt}/${maxRetries}). Retrying in ${delay}ms...`, |
| 141 | ); |
| 142 | await new Promise((resolve) => setTimeout(resolve, delay)); |
| 143 | continue; |
| 144 | } |
| 145 | |
| 146 | throw error; |
| 147 | } |
| 148 | } |
| 149 | throw new PentestError( |
| 150 | `Git command failed after ${maxRetries} retries`, |
| 151 | 'filesystem', |
| 152 | true, // Retryable - transient git lock issues |
| 153 | { maxRetries, description }, |
| 154 | ErrorCode.GIT_CHECKPOINT_FAILED, |
| 155 | ); |
| 156 | } finally { |
| 157 | gitSemaphore.release(); |
| 158 | } |
| 159 | } |
| 160 | |
| 161 | // Two-phase reset: hard reset (tracked files) + clean (untracked files) |
| 162 | export async function rollbackGitWorkspace( |
no test coverage detected