/** * Git push operations with timeout, retry, and recovery * * WHY this is the largest git module (328 lines): * Push is the most complex git operation we perform: * - Process management (spawn for timeout control) * - Authentication (one-shot auth URL for push) * - Error handling (parse stderr for specific error types) * - Retry logic (pull and push again on rejection) * * WHY use spawn() instead of simple-git: * simple-git's push() returns a Promise that can't be cancelled. If it hangs, * the process runs forever. spawn() gives us direct process control so we can * SIGKILL on timeout or Ctrl+C interruption. * * WHY one-shot push URL with token (not origin + http.extraheader): * CI often has `origin` set to `https://***@github.com/...` or a token that no * longer matches GITHUB_TOKEN; git then still asks for a password. Passing * git -c credential.helper= push https://<token>@github.com/org/repo.git HEAD:branch * embeds the current token in the URL only for this process (not persisted to * .git/config like `remote set-url`). * * DESIGN: This module is intentionally kept together despite its size because * the push logic is tightly coupled - timeout handling, auth, and retry all * interact with each other. */import type { SimpleGit } from 'simple-git';import { spawn, execFileSync } from 'child_process';import { existsSync, rmSync } from 'fs';import { join } from 'path';import { debug, formatNumber } from '../logger.js';import { cleanupGitState, continueRebase } from './git-merge.js';import { redactUrlCredentials } from './redact-url.js';import { buildHttpsPushUrlWithToken, httpsRemoteHasUserinfo, stripHttpsUserinfo,} from './git-push-auth-url.js'; /** * Result of a git push operation. * Canonical definition; commit.ts re-exports for backward compatibility. */export interface PushResult { success: boolean; rejected?: boolean; error?: string; /** True when push succeeded but remote already had our commits (nothing to push). * WHY: Caller skips the post-push bot wait when nothing was pushed to avoid a 300s wait. */ nothingToPush?: boolean;} /** * Push changes to remote with timeout and signal handling. * Canonical implementation; commit.ts re-exports for backward compatibility. * * WHY spawn instead of simple-git: simple-git's promises can't be cancelled, * and the underlying git process keeps running even after timeout. Using * spawn directly lets us SIGKILL the process on timeout or Ctrl+C. * * WHY 30s timeout (reduced from 60s): Push should be fast. If it takes longer, * something is wrong (auth prompt, network). 30s is still generous. * * Returns PushResult instead of throwing for rejected pushes, allowing caller * to handle pull-and-retry logic. */export async function push(git: SimpleGit, branch: string, force = false, githubToken?: string): Promise<PushResult> { const PUSH_TIMEOUT_MS = 30_000; // 30 seconds (reduced from 60) // Get the workdir from the git instance // WHY multiple fallbacks: simple-git internal _baseDir may be undefined in some versions // Use git rev-parse as the most reliable source let workdir: string; try { // Most reliable: ask git itself for the repo root workdir = (await git.revparse(['--show-toplevel'])).trim(); } catch { // Fallback to internal property or cwd workdir = (git as any)._baseDir || process.cwd(); debug('Using fallback workdir', { workdir, method: (git as any)._baseDir ? '_baseDir' : 'cwd' }); } let pushArgs: string[] = ['push', 'origin', `HEAD:${branch}`]; if (force) pushArgs.push('--force'); let disableCredentialHelpers = false; try { const remoteUrl = execFileSync('git', ['remote', 'get-url', 'origin'], { cwd: workdir, encoding: 'utf8' }).trim(); const hasTokenInUrl = httpsRemoteHasUserinfo(remoteUrl); const cleanHttps = remoteUrl.startsWith('https://') ? stripHttpsUserinfo(remoteUrl) : remoteUrl; if (githubToken && cleanHttps.startsWith('https://')) { const pushUrl = buildHttpsPushUrlWithToken(cleanHttps, githubToken); pushArgs = ['push', ...(force ? ['--force'] : []), pushUrl, `HEAD:${branch}`]; disableCredentialHelpers = true; debug('Pre-push check', { hasTokenInUrl, usingOneShotPushUrlWithToken: true }); } else if (githubToken && !cleanHttps.startsWith('https://')) { debug('Remote URL is SSH — token injection skipped; push will use SSH credentials.'); } else if (!hasTokenInUrl && !githubToken) { debug('WARNING: Remote URL does not contain token and no token provided - push may fail'); } else if (hasTokenInUrl && !githubToken) { pushArgs = ['push', 'origin', `HEAD:${branch}`]; if (force) pushArgs.push('--force'); disableCredentialHelpers = true; debug('Pre-push check', { hasTokenInUrl, usingOriginUrlToken: true }); } else { debug('Pre-push check', { hasTokenInUrl }); } if (disableCredentialHelpers) { pushArgs = [ '-c', 'credential.helper=', '-c', 'credential.https://github.com.helper=', ...pushArgs, ]; } } catch (e) { debug('Could not check remote URL', { error: redactUrlCredentials(String(e)) }); } const fullCommand = `git ${pushArgs.join(' ')}`; debug('Starting git push', { command: redactUrlCredentials(fullCommand), workdir }); const spawnEnv = { ...process.env }; if (disableCredentialHelpers) { spawnEnv.GIT_TERMINAL_PROMPT = '0'; } return new Promise((resolve) => { const gitProcess = spawn('git', pushArgs, { cwd: workdir, stdio: ['ignore', 'pipe', 'pipe'], env: spawnEnv, }); let stdout = ''; let stderr = ''; let killed = false; let settled = false; const settle = (result: { success: boolean; nothingToPush?: boolean; rejected?: boolean; error?: string }) => { if (settled) return; settled = true; resolve(result); }; // Log output as it comes in (git push progress goes to stderr) gitProcess.stdout?.on('data', (data) => { stdout += data.toString(); debug('git push stdout', redactUrlCredentials(data.toString().trim())); }); gitProcess.stderr?.on('data', (data) => { stderr += data.toString(); // Show progress (git push writes progress to stderr) const line = redactUrlCredentials(data.toString().trim()); if (line && !line.includes('Username') && !line.includes('Password')) { debug('git push progress', line); } }); // Timeout - kill the process const timeout = setTimeout(() => { killed = true; gitProcess.kill('SIGKILL'); const errMsg = [ `Push timed out after 30 seconds.`, `Command: ${redactUrlCredentials(fullCommand)}`, `Workdir: ${workdir}`, `This usually means:`, ` - Network issue (check connectivity)`, ` - Auth issue (token missing/expired)`, ` - Git waiting for interactive input`, stderr ? `stderr: ${redactUrlCredentials(stderr)}` : '', ].filter(Boolean).join('\n'); settle({ success: false, error: errMsg }); }, PUSH_TIMEOUT_MS); // Handle Ctrl+C - kill the git process and settle so callers don't hang const sigintHandler = () => { if (killed) return; killed = true; gitProcess.kill('SIGKILL'); clearTimeout(timeout); process.removeListener('SIGINT', sigintHandler); settle({ success: false, error: 'Push cancelled by user (SIGINT)' }); }; process.on('SIGINT', sigintHandler); gitProcess.on('close', (code) => { clearTimeout(timeout); process.removeListener('SIGINT', sigintHandler); if (killed) { // Timeout or SIGINT already settled the promise return; } if (code === 0) { const nothingToPush = /everything up-to-date/i.test(stderr) || /everything up-to-date/i.test(stdout); debug('Git push completed', { branch, nothingToPush }); settle({ success: true, nothingToPush: nothingToPush || undefined }); } else { const isRejected = stderr.includes('rejected') && (stderr.includes('fetch first') || stderr.includes('non-fast-forward')); if (isRejected) { debug('Push rejected - remote has newer commits', { stderr: redactUrlCredentials(stderr) }); settle({ success: false, rejected: true, error: 'Push rejected: remote has newer commits. Need to pull first.', }); } else { // WHY: output.log audit babylon#1213 — push failed with "refusing to allow a Personal Access Token to create or update workflow … without `workflow` scope". Surface a clear hint. const workflowScopeDenied = /refusing to allow.*(?:create or update workflow|workflow.*without.*workflow.*scope)/i.test(stderr) || /without\s*[`']workflow[`']\s*scope/i.test(stderr); const baseError = `Git push failed with code ${code}\nCommand: ${redactUrlCredentials(fullCommand)}\nWorkdir: ${workdir}\nstderr: ${redactUrlCredentials(stderr)}`; const error = workflowScopeDenied ? `${baseError}\n\nHint: GitHub rejected the push because your token does not have the 'workflow' scope. To modify .github/workflows files, add the workflow scope to your Personal Access Token, or fix workflow files manually.` : baseError; settle({ success: false, error }); } } }); gitProcess.on('error', (err) => { clearTimeout(timeout); process.removeListener('SIGINT', sigintHandler); settle({ success: false, error: `Git push failed: ${err.message}\nCommand: ${redactUrlCredentials(fullCommand)}\nWorkdir: ${workdir}`, }); }); });} /** * Result of pushWithRetry when push succeeds. */export interface PushWithRetryResult { success: boolean; error?: string; conflictedFiles?: string[]; // Files with conflicts if rebase failed /** True when remote already had our commits (nothing to push). Skip bot wait. */ nothingToPush?: boolean; /** True when nothingToPush occurred after a rebase (e.g. remote already had commits from a previous run). Callers can show a more specific message. */ nothingToPushAfterRebase?: boolean;} /** * Push with auto-retry on rejection. * If push is rejected because remote has newer commits, fetches and rebases, then retries. * * WHY: Common scenario when CodeRabbit or another user pushes while prr is working. * Auto-retry makes the workflow smoother without manual intervention. * * WHY rebase instead of merge: Keeps history clean. Our fix commits go on top. * * NEW: onConflict callback allows caller to resolve conflicts (e.g., with LLM). * If provided and conflicts occur, calls callback. If callback returns true (resolved), * continues the rebase and retries push. * * On rebase failure we run rebase --abort only (no cleanupGitState, to preserve caller's commits). * WHY: Abort preserves commits; full cleanup is for stale/corrupt state so the next run * doesn't hit "rebase-merge directory already exists". */async function removeStuckRebaseDirs(git: SimpleGit): Promise<void> { try { const wd = (await git.revparse(['--show-toplevel'])).trim(); const gitDir = join(wd, '.git'); for (const name of ['rebase-merge', 'rebase-apply']) { const p = join(gitDir, name); if (existsSync(p)) { rmSync(p, { recursive: true }); debug('Removed stuck rebase dir so next run can proceed', { path: name }); } } } catch { // best-effort }} export async function pushWithRetry( git: SimpleGit, branch: string, options?: { force?: boolean; onPullNeeded?: () => void; githubToken?: string; onConflict?: (conflictedFiles: string[]) => Promise<boolean>; // Returns true if conflicts resolved maxRetries?: number; // Max push retries (default 3) }): Promise<PushWithRetryResult> { const requestedRetries = options?.maxRetries ?? 3; const maxRetries = (Number.isInteger(requestedRetries) && requestedRetries >= 0) ? requestedRetries : 0; if (maxRetries !== requestedRetries) { debug('pushWithRetry: invalid maxRetries, using 0 (one attempt)', { requestedRetries }); } const maxAttempts = maxRetries + 1; let attempts = 0; while (attempts < maxAttempts) { attempts++; const result = await push(git, branch, options?.force, options?.githubToken); if (result.success) { if (result.nothingToPush && attempts > 1) { debug('Push after rebase resulted in nothing-to-push — remote already has these commits (likely from a previous run)'); } return { success: true, nothingToPush: result.nothingToPush, nothingToPushAfterRebase: result.nothingToPush && attempts > 1, }; } if (!result.rejected) { // Non-rejected failure (auth, network, etc.) - don't retry throw new Error(result.error || 'Push failed'); } if (attempts >= maxAttempts) { break; } // Push was rejected - remote has newer commits debug(`Push rejected (attempt ${attempts}/${maxAttempts}), attempting fetch + rebase + retry`); options?.onPullNeeded?.(); const ref = `origin/${branch}`; try { await git.raw(['rev-parse', '--verify', ref]); debug('Rebase target verified', { ref }); } catch { // Ref may be missing when repo was cloned with --single-branch (refspec doesn't include this branch). // Add refspec and fetch so rebase has a valid upstream (same pattern as git-clone-core additionalBranches). debug('Rebase target missing locally, adding refspec and fetching', { ref }); try { await git.raw(['remote', 'set-branches', '--add', 'origin', branch]); await git.fetch('origin', branch); } catch (fetchErr) { const msg = fetchErr instanceof Error ? fetchErr.message : String(fetchErr); throw new Error( `Branch ${branch} does not exist on remote (fetch failed). If using a single-branch clone, create the branch on the remote first. ${msg}` ); } } // Fetch and rebase to handle divergent branches try { await git.fetch('origin', branch); debug('Fetch successful'); await git.raw(['rev-parse', '--verify', ref]); debug('Rebase target verified before rebase', { ref }); // Then rebase our commits on top of remote await git.rebase([ref]); debug('Rebase successful, retrying push'); // Loop continues to retry push } catch (syncError) { const syncMsg = syncError instanceof Error ? syncError.message : String(syncError); debug('Rebase failed', { error: redactUrlCredentials(syncMsg), attempt: attempts }); // Check if it's a conflict if (syncMsg.includes('CONFLICT') || syncMsg.includes('conflict')) { // Get conflicted files const status = await git.status(); const conflictedFiles = status.conflicted || []; // If we have a conflict handler, try to use it if (options?.onConflict && conflictedFiles.length > 0) { debug('Calling onConflict handler', { files: conflictedFiles }); try { const resolved = await options.onConflict(conflictedFiles); if (resolved) { // Conflicts resolved - stage files and continue rebase debug('Conflicts resolved by handler, continuing rebase'); await git.add('.'); try { await continueRebase(git); debug('Rebase continued successfully'); // Loop continues to retry push continue; } catch (continueError) { // Rebase continue failed - check if more conflicts or done const continueMsg = continueError instanceof Error ? continueError.message : String(continueError); if (continueMsg.includes('No changes') || continueMsg.includes('nothing to commit')) { // Rebase is actually done debug('Rebase complete (no more changes)'); continue; } // More conflicts - could loop but for now bail debug('Rebase continue failed', { error: redactUrlCredentials(continueMsg) }); } } } catch (handlerError) { const handlerMsg = handlerError instanceof Error ? handlerError.message : String(handlerError); debug('onConflict handler failed', { error: redactUrlCredentials(handlerMsg) }); } } if (conflictedFiles.length > 0) { const handlerNote = options?.onConflict ? 'onConflict did not finish the rebase (or returned false). ' : 'No onConflict handler was provided. '; console.warn( `${handlerNote}Rebase conflicts in ${formatNumber(conflictedFiles.length)} file(s): ${conflictedFiles.join(', ')}`, ); } // WHY only abort, not cleanupGitState: cleanupGitState does reset --hard + clean -fd and // destroys the caller's commits (e.g. split-exec's cherry-picks). Abort preserves commits. try { await git.rebase(['--abort']); } catch (abortErr) { debug('rebase --abort failed (rebase state may be stale); not running cleanup to preserve local commits', { err: String(abortErr) }); await removeStuckRebaseDirs(git); } let workdirMsg = ''; try { const workdir = (await git.revparse(['--show-toplevel'])).trim(); const fileList = conflictedFiles.join(' '); const allWorkflow = conflictedFiles.every((f) => f.startsWith('.github/workflows/')); const resolveCmd = allWorkflow ? `git checkout --theirs -- ${fileList} && git add ${fileList} && git rebase --continue` : `git add ${fileList} && git rebase --continue`; workdirMsg = `\nWorkdir: ${workdir}\nResolve then continue: cd ${workdir} && ${resolveCmd}`; } catch { // best-effort } throw new Error(`Push rejected and rebase has conflicts in: ${conflictedFiles.join(', ')}. Manual resolution needed.${workdirMsg}\nOriginal: ${result.error}`); } // Non-conflict rebase failure (e.g. invalid upstream when ref wasn't fetched). Abort only; do not cleanup. try { await git.rebase(['--abort']); } catch (abortErr) { debug('rebase --abort failed; not running cleanup to preserve local commits', { err: String(abortErr) }); await removeStuckRebaseDirs(git); } const refHint = /invalid upstream|ref.*not found/i.test(syncMsg) ? ' If using a --single-branch clone, ensure the branch ref was fetched (e.g. additionalBranches or git remote set-branches --add origin <branch>).' : ''; throw new Error(`Push rejected and sync failed: ${syncMsg}${refHint}\nOriginal: ${result.error}`); } } // Exhausted retries throw new Error(`Push failed after ${maxAttempts} attempts. Remote may be receiving concurrent pushes.`);}
/**
* Git push operations with timeout, retry, and recovery
*
* WHY this is the largest git module (328 lines):
* Push is the most complex git operation we perform:
* - Process management (spawn for timeout control)
* - Authentication (one-shot auth URL for push)
* - Error handling (parse stderr for specific error types)
* - Retry logic (pull and push again on rejection)
*
* WHY use spawn() instead of simple-git:
* simple-git's push() returns a Promise that can't be cancelled. If it hangs,
* the process runs forever. spawn() gives us direct process control so we can
* SIGKILL on timeout or Ctrl+C interruption.
*
* WHY one-shot push URL with token (not origin + http.extraheader):
* CI often has `origin` set to `https://***@github.com/...` or a token that no
* longer matches GITHUB_TOKEN; git then still asks for a password. Passing
* git -c credential.helper= push https://<token>@github.com/org/repo.git HEAD:branch
* embeds the current token in the URL only for this process (not persisted to
* .git/config like `remote set-url`).
*
* DESIGN: This module is intentionally kept together despite its size because
* the push logic is tightly coupled - timeout handling, auth, and retry all
* interact with each other.
*/
import type { SimpleGit } from 'simple-git';
import { spawn, execFileSync } from 'child_process';
import { existsSync, rmSync } from 'fs';
import { join } from 'path';
import { debug, formatNumber } from '../logger.js';
import { cleanupGitState, continueRebase } from './git-merge.js';
import { redactUrlCredentials } from './redact-url.js';
import {
buildHttpsPushUrlWithToken,
httpsRemoteHasUserinfo,
stripHttpsUserinfo,
} from './git-push-auth-url.js';
/**
* Result of a git push operation.
* Canonical definition; commit.ts re-exports for backward compatibility.
*/
export interface PushResult {
success: boolean;
rejected?: boolean;
error?: string;
/** True when push succeeded but remote already had our commits (nothing to push).
* WHY: Caller skips the post-push bot wait when nothing was pushed to avoid a 300s wait. */
nothingToPush?: boolean;
}
/**
* Push changes to remote with timeout and signal handling.
* Canonical implementation; commit.ts re-exports for backward compatibility.
*
* WHY spawn instead of simple-git: simple-git's promises can't be cancelled,
* and the underlying git process keeps running even after timeout. Using
* spawn directly lets us SIGKILL the process on timeout or Ctrl+C.
*
* WHY 30s timeout (reduced from 60s): Push should be fast. If it takes longer,
* something is wrong (auth prompt, network). 30s is still generous.
*
* Returns PushResult instead of throwing for rejected pushes, allowing caller
* to handle pull-and-retry logic.
*/
export async function push(git: SimpleGit, branch: string, force = false, githubToken?: string): Promise<PushResult> {
const PUSH_TIMEOUT_MS = 30_000; // 30 seconds (reduced from 60)
// Get the workdir from the git instance
// WHY multiple fallbacks: simple-git internal _baseDir may be undefined in some versions
// Use git rev-parse as the most reliable source
let workdir: string;
try {
// Most reliable: ask git itself for the repo root
workdir = (await git.revparse(['--show-toplevel'])).trim();
} catch {
// Fallback to internal property or cwd
workdir = (git as any)._baseDir || process.cwd();
debug('Using fallback workdir', { workdir, method: (git as any)._baseDir ? '_baseDir' : 'cwd' });
}
let pushArgs: string[] = ['push', 'origin', `HEAD:${branch}`];
if (force) pushArgs.push('--force');
let disableCredentialHelpers = false;
try {
const remoteUrl = execFileSync('git', ['remote', 'get-url', 'origin'], { cwd: workdir, encoding: 'utf8' }).trim();
const hasTokenInUrl = httpsRemoteHasUserinfo(remoteUrl);
const cleanHttps =
remoteUrl.startsWith('https://') ? stripHttpsUserinfo(remoteUrl) : remoteUrl;
if (githubToken && cleanHttps.startsWith('https://')) {
const pushUrl = buildHttpsPushUrlWithToken(cleanHttps, githubToken);
pushArgs = ['push', ...(force ? ['--force'] : []), pushUrl, `HEAD:${branch}`];
disableCredentialHelpers = true;
debug('Pre-push check', { hasTokenInUrl, usingOneShotPushUrlWithToken: true });
} else if (githubToken && !cleanHttps.startsWith('https://')) {
debug('Remote URL is SSH — token injection skipped; push will use SSH credentials.');
} else if (!hasTokenInUrl && !githubToken) {
debug('WARNING: Remote URL does not contain token and no token provided - push may fail');
} else if (hasTokenInUrl && !githubToken) {
pushArgs = ['push', 'origin', `HEAD:${branch}`];
if (force) pushArgs.push('--force');
disableCredentialHelpers = true;
debug('Pre-push check', { hasTokenInUrl, usingOriginUrlToken: true });
} else {
debug('Pre-push check', { hasTokenInUrl });
}
if (disableCredentialHelpers) {
pushArgs = [
'-c',
'credential.helper=',
'-c',
'credential.https://github.com.helper=',
...pushArgs,
];
}
} catch (e) {
debug('Could not check remote URL', { error: redactUrlCredentials(String(e)) });
}
const fullCommand = `git ${pushArgs.join(' ')}`;
debug('Starting git push', { command: redactUrlCredentials(fullCommand), workdir });
const spawnEnv = { ...process.env };
if (disableCredentialHelpers) {
spawnEnv.GIT_TERMINAL_PROMPT = '0';
}
return new Promise((resolve) => {
const gitProcess = spawn('git', pushArgs, {
cwd: workdir,
stdio: ['ignore', 'pipe', 'pipe'],
env: spawnEnv,
});
let stdout = '';
let stderr = '';
let killed = false;
let settled = false;
const settle = (result: { success: boolean; nothingToPush?: boolean; rejected?: boolean; error?: string }) => {
if (settled) return;
settled = true;
resolve(result);
};
// Log output as it comes in (git push progress goes to stderr)
gitProcess.stdout?.on('data', (data) => {
stdout += data.toString();
debug('git push stdout', redactUrlCredentials(data.toString().trim()));
});
gitProcess.stderr?.on('data', (data) => {
stderr += data.toString();
// Show progress (git push writes progress to stderr)
const line = redactUrlCredentials(data.toString().trim());
if (line && !line.includes('Username') && !line.includes('Password')) {
debug('git push progress', line);
}
});
// Timeout - kill the process
const timeout = setTimeout(() => {
killed = true;
gitProcess.kill('SIGKILL');
const errMsg = [
`Push timed out after 30 seconds.`,
`Command: ${redactUrlCredentials(fullCommand)}`,
`Workdir: ${workdir}`,
`This usually means:`,
` - Network issue (check connectivity)`,
` - Auth issue (token missing/expired)`,
` - Git waiting for interactive input`,
stderr ? `stderr: ${redactUrlCredentials(stderr)}` : '',
].filter(Boolean).join('\n');
settle({ success: false, error: errMsg });
}, PUSH_TIMEOUT_MS);
// Handle Ctrl+C - kill the git process and settle so callers don't hang
const sigintHandler = () => {
if (killed) return;
killed = true;
gitProcess.kill('SIGKILL');
clearTimeout(timeout);
process.removeListener('SIGINT', sigintHandler);
settle({ success: false, error: 'Push cancelled by user (SIGINT)' });
};
process.on('SIGINT', sigintHandler);
gitProcess.on('close', (code) => {
clearTimeout(timeout);
process.removeListener('SIGINT', sigintHandler);
if (killed) {
// Timeout or SIGINT already settled the promise
return;
}
if (code === 0) {
const nothingToPush = /everything up-to-date/i.test(stderr) || /everything up-to-date/i.test(stdout);
debug('Git push completed', { branch, nothingToPush });
settle({ success: true, nothingToPush: nothingToPush || undefined });
} else {
const isRejected = stderr.includes('rejected') &&
(stderr.includes('fetch first') || stderr.includes('non-fast-forward'));
if (isRejected) {
debug('Push rejected - remote has newer commits', { stderr: redactUrlCredentials(stderr) });
settle({
success: false,
rejected: true,
error: 'Push rejected: remote has newer commits. Need to pull first.',
});
} else {
// WHY: output.log audit babylon#1213 — push failed with "refusing to allow a Personal Access Token to create or update workflow … without `workflow` scope". Surface a clear hint.
const workflowScopeDenied = /refusing to allow.*(?:create or update workflow|workflow.*without.*workflow.*scope)/i.test(stderr) || /without\s*[`']workflow[`']\s*scope/i.test(stderr);
const baseError = `Git push failed with code ${code}\nCommand: ${redactUrlCredentials(fullCommand)}\nWorkdir: ${workdir}\nstderr: ${redactUrlCredentials(stderr)}`;
const error = workflowScopeDenied
? `${baseError}\n\nHint: GitHub rejected the push because your token does not have the 'workflow' scope. To modify .github/workflows files, add the workflow scope to your Personal Access Token, or fix workflow files manually.`
: baseError;
settle({ success: false, error });
}
}
});
gitProcess.on('error', (err) => {
clearTimeout(timeout);
process.removeListener('SIGINT', sigintHandler);
settle({
success: false,
error: `Git push failed: ${err.message}\nCommand: ${redactUrlCredentials(fullCommand)}\nWorkdir: ${workdir}`,
});
});
});
}
/**
* Result of pushWithRetry when push succeeds.
*/
export interface PushWithRetryResult {
success: boolean;
error?: string;
conflictedFiles?: string[]; // Files with conflicts if rebase failed
/** True when remote already had our commits (nothing to push). Skip bot wait. */
nothingToPush?: boolean;
/** True when nothingToPush occurred after a rebase (e.g. remote already had commits from a previous run). Callers can show a more specific message. */
nothingToPushAfterRebase?: boolean;
}
/**
* Push with auto-retry on rejection.
* If push is rejected because remote has newer commits, fetches and rebases, then retries.
*
* WHY: Common scenario when CodeRabbit or another user pushes while prr is working.
* Auto-retry makes the workflow smoother without manual intervention.
*
* WHY rebase instead of merge: Keeps history clean. Our fix commits go on top.
*
* NEW: onConflict callback allows caller to resolve conflicts (e.g., with LLM).
* If provided and conflicts occur, calls callback. If callback returns true (resolved),
* continues the rebase and retries push.
*
* On rebase failure we run rebase --abort only (no cleanupGitState, to preserve caller's commits).
* WHY: Abort preserves commits; full cleanup is for stale/corrupt state so the next run
* doesn't hit "rebase-merge directory already exists".
*/
async function removeStuckRebaseDirs(git: SimpleGit): Promise<void> {
try {
const wd = (await git.revparse(['--show-toplevel'])).trim();
const gitDir = join(wd, '.git');
for (const name of ['rebase-merge', 'rebase-apply']) {
const p = join(gitDir, name);
if (existsSync(p)) {
rmSync(p, { recursive: true });
debug('Removed stuck rebase dir so next run can proceed', { path: name });
}
}
} catch {
// best-effort
}
}
export async function pushWithRetry(
git: SimpleGit,
branch: string,
options?: {
force?: boolean;
onPullNeeded?: () => void;
githubToken?: string;
onConflict?: (conflictedFiles: string[]) => Promise<boolean>; // Returns true if conflicts resolved
maxRetries?: number; // Max push retries (default 3)
}
): Promise<PushWithRetryResult> {
const requestedRetries = options?.maxRetries ?? 3;
const maxRetries = (Number.isInteger(requestedRetries) && requestedRetries >= 0)
? requestedRetries
: 0;
if (maxRetries !== requestedRetries) {
debug('pushWithRetry: invalid maxRetries, using 0 (one attempt)', { requestedRetries });
}
const maxAttempts = maxRetries + 1;
let attempts = 0;
while (attempts < maxAttempts) {
attempts++;
const result = await push(git, branch, options?.force, options?.githubToken);
if (result.success) {
if (result.nothingToPush && attempts > 1) {
debug('Push after rebase resulted in nothing-to-push — remote already has these commits (likely from a previous run)');
}
return {
success: true,
nothingToPush: result.nothingToPush,
nothingToPushAfterRebase: result.nothingToPush && attempts > 1,
};
}
if (!result.rejected) {
// Non-rejected failure (auth, network, etc.) - don't retry
throw new Error(result.error || 'Push failed');
}
if (attempts >= maxAttempts) {
break;
}
// Push was rejected - remote has newer commits
debug(`Push rejected (attempt ${attempts}/${maxAttempts}), attempting fetch + rebase + retry`);
options?.onPullNeeded?.();
const ref = `origin/${branch}`;
try {
await git.raw(['rev-parse', '--verify', ref]);
debug('Rebase target verified', { ref });
} catch {
// Ref may be missing when repo was cloned with --single-branch (refspec doesn't include this branch).
// Add refspec and fetch so rebase has a valid upstream (same pattern as git-clone-core additionalBranches).
debug('Rebase target missing locally, adding refspec and fetching', { ref });
try {
await git.raw(['remote', 'set-branches', '--add', 'origin', branch]);
await git.fetch('origin', branch);
} catch (fetchErr) {
const msg = fetchErr instanceof Error ? fetchErr.message : String(fetchErr);
throw new Error(
`Branch ${branch} does not exist on remote (fetch failed). If using a single-branch clone, create the branch on the remote first. ${msg}`
);
}
}
// Fetch and rebase to handle divergent branches
try {
await git.fetch('origin', branch);
debug('Fetch successful');
await git.raw(['rev-parse', '--verify', ref]);
debug('Rebase target verified before rebase', { ref });
// Then rebase our commits on top of remote
await git.rebase([ref]);
debug('Rebase successful, retrying push');
// Loop continues to retry push
} catch (syncError) {
const syncMsg = syncError instanceof Error ? syncError.message : String(syncError);
debug('Rebase failed', { error: redactUrlCredentials(syncMsg), attempt: attempts });
// Check if it's a conflict
if (syncMsg.includes('CONFLICT') || syncMsg.includes('conflict')) {
// Get conflicted files
const status = await git.status();
const conflictedFiles = status.conflicted || [];
// If we have a conflict handler, try to use it
if (options?.onConflict && conflictedFiles.length > 0) {
debug('Calling onConflict handler', { files: conflictedFiles });
try {
const resolved = await options.onConflict(conflictedFiles);
if (resolved) {
// Conflicts resolved - stage files and continue rebase
debug('Conflicts resolved by handler, continuing rebase');
await git.add('.');
try {
await continueRebase(git);
debug('Rebase continued successfully');
// Loop continues to retry push
continue;
} catch (continueError) {
// Rebase continue failed - check if more conflicts or done
const continueMsg = continueError instanceof Error ? continueError.message : String(continueError);
if (continueMsg.includes('No changes') || continueMsg.includes('nothing to commit')) {
// Rebase is actually done
debug('Rebase complete (no more changes)');
continue;
}
// More conflicts - could loop but for now bail
debug('Rebase continue failed', { error: redactUrlCredentials(continueMsg) });
}
}
} catch (handlerError) {
const handlerMsg = handlerError instanceof Error ? handlerError.message : String(handlerError);
debug('onConflict handler failed', { error: redactUrlCredentials(handlerMsg) });
}
}
if (conflictedFiles.length > 0) {
const handlerNote = options?.onConflict
? 'onConflict did not finish the rebase (or returned false). '
: 'No onConflict handler was provided. ';
console.warn(
`${handlerNote}Rebase conflicts in ${formatNumber(conflictedFiles.length)} file(s): ${conflictedFiles.join(', ')}`,
);
}
// WHY only abort, not cleanupGitState: cleanupGitState does reset --hard + clean -fd and
// destroys the caller's commits (e.g. split-exec's cherry-picks). Abort preserves commits.
try {
await git.rebase(['--abort']);
} catch (abortErr) {
debug('rebase --abort failed (rebase state may be stale); not running cleanup to preserve local commits', { err: String(abortErr) });
await removeStuckRebaseDirs(git);
}
let workdirMsg = '';
try {
const workdir = (await git.revparse(['--show-toplevel'])).trim();
const fileList = conflictedFiles.join(' ');
const allWorkflow = conflictedFiles.every((f) => f.startsWith('.github/workflows/'));
const resolveCmd = allWorkflow
? `git checkout --theirs -- ${fileList} && git add ${fileList} && git rebase --continue`
: `git add ${fileList} && git rebase --continue`;
workdirMsg = `\nWorkdir: ${workdir}\nResolve then continue: cd ${workdir} && ${resolveCmd}`;
} catch {
// best-effort
}
throw new Error(`Push rejected and rebase has conflicts in: ${conflictedFiles.join(', ')}. Manual resolution needed.${workdirMsg}\nOriginal: ${result.error}`);
}
// Non-conflict rebase failure (e.g. invalid upstream when ref wasn't fetched). Abort only; do not cleanup.
try {
await git.rebase(['--abort']);
} catch (abortErr) {
debug('rebase --abort failed; not running cleanup to preserve local commits', { err: String(abortErr) });
await removeStuckRebaseDirs(git);
}
const refHint = /invalid upstream|ref.*not found/i.test(syncMsg)
? ' If using a --single-branch clone, ensure the branch ref was fetched (e.g. additionalBranches or git remote set-branches --add origin <branch>).'
: '';
throw new Error(`Push rejected and sync failed: ${syncMsg}${refHint}\nOriginal: ${result.error}`);
}
}
// Exhausted retries
throw new Error(`Push failed after ${maxAttempts} attempts. Remote may be receiving concurrent pushes.`);
}