/** * Git pull operations. * WHY fetchOriginBranch + options: pull's internal fetch must use the same one-shot token auth as conflict * check so we don't prompt for password when remote has no credentials (see git-conflicts.ts). */ import type { SimpleGit } from 'simple-git'; import { debug, formatNumber } from '../logger.js'; import { abortMerge } from './git-merge.js'; import { fetchOriginBranch, type FetchOptions } from './git-conflicts.js'; export async function pullLatest( git: SimpleGit, branch: string, options?: FetchOptions ): Promise<{ success: boolean; error?: string; stashConflicts?: string[]; stashLeft?: boolean; rebased?: boolean }> { debug('Pulling latest changes', { branch }); // Check for uncommitted changes and stash them // WHY: Interrupted runs may leave uncommitted changes that block pulls const status = await git.status(); const hasLocalChanges = !status.isClean(); let didStash = false; if (hasLocalChanges) { debug('Stashing local changes before pull', { modified: status.modified.length, created: status.created.length, deleted: status.deleted.length, }); try { await git.stash(['push', '-u', '-m', 'prr-auto-stash-before-pull']); didStash = true; console.log( ` Stashed ${formatNumber(status.modified.length + status.created.length + status.deleted.length)} local changes`, ); } catch (stashError) { debug('Failed to stash', { error: stashError }); console.warn( ' ⚠ Could not stash local changes before pull — aborting pull to avoid merging/rebasing on a dirty tree (resolve or stash manually, then retry).', ); return { success: false, error: stashError instanceof Error ? stashError.message : String(stashError), stashLeft: false, }; } } // Helper to restore stash on failure const restoreStashOnFailure = async () => { if (didStash) { debug('Restoring stash after failure'); try { await git.stash(['pop']); console.log(' Restored stashed changes'); } catch { console.log(' ⚠ Changes still in stash (use `git stash pop` to restore)'); } } }; try { // First, fetch to see what we're dealing with (use fetchOriginBranch so token auth works) await fetchOriginBranch(git, branch, options); // Check if branches have diverged const postFetchStatus = await git.status(); const ahead = postFetchStatus.ahead || 0; const behind = postFetchStatus.behind || 0; debug('Post-fetch status', { ahead, behind }); let rebased = false; if (ahead > 0 && behind > 0) { // Branches have diverged - need to rebase our commits on top of remote debug('Branches diverged, rebasing local commits on remote'); console.log( ` Rebasing ${formatNumber(ahead)} local commit(s) onto ${formatNumber(behind)} remote commit(s)...`, ); try { await git.rebase([`origin/${branch}`]); rebased = true; console.log(' Rebase successful'); } catch (rebaseError) { const rebaseMsg = rebaseError instanceof Error ? rebaseError.message : String(rebaseError); debug('Rebase failed', { error: rebaseMsg }); // Check for rebase conflicts if (rebaseMsg.includes('CONFLICT') || rebaseMsg.includes('conflict')) { // Don't abort - leave conflicts for programmatic resolution // WHY: If we abort, git status shows no conflicts and we can't resolve them debug('Rebase has conflicts - leaving in conflicted state for resolution'); console.log( ' Hint: resolve conflicted files, then `git rebase --continue`, or `git rebase --abort` to undo.', ); await restoreStashOnFailure(); return { success: false, error: `Rebase conflicts detected` }; } // Other rebase failure - abort and fall back to merge try { await git.rebase(['--abort']); } catch { // Ignore } debug('Rebase failed, falling back to merge'); console.log(' Rebase failed, trying merge...'); try { await git.merge([`origin/${branch}`]); console.log(' Merged remote changes'); } catch (mergeError) { const mergeMsg = mergeError instanceof Error ? mergeError.message : String(mergeError); await abortMerge(git); await restoreStashOnFailure(); return { success: false, error: `Failed to sync with remote: ${mergeMsg}` }; } } } else if (behind > 0) { // Just behind - simple fast-forward pull debug('Fast-forward pull'); await git.pull('origin', branch, { '--ff-only': null }); } // If only ahead (or equal), nothing to pull // If we stashed, try to restore if (didStash) { debug('Restoring stashed changes'); try { await git.stash(['pop']); console.log(' Restored stashed changes'); } catch (popError) { const popMessage = popError instanceof Error ? popError.message : String(popError); debug('Stash pop failed', { error: popMessage }); // Check if there are conflicts from stash pop const postPopStatus = await git.status(); if (postPopStatus.conflicted && postPopStatus.conflicted.length > 0) { console.log(` ⚠ Stash conflicts in: ${postPopStatus.conflicted.join(', ')}`); // Keep the stash conflicts - user's changes need to be reconciled return { success: true, stashConflicts: postPopStatus.conflicted, rebased }; } // If pop failed but no conflicts, the stash is still there // Keep it so users can recover their changes console.warn(' ⚠ Could not restore stashed changes - kept in stash list'); console.warn(' Use `git stash list` and `git stash pop` to recover (message: prr-auto-stash-before-pull)'); return { success: true, stashLeft: true, rebased }; } } return { success: true, rebased }; } catch (error) { const message = error instanceof Error ? error.message : String(error); debug('Pull/sync failed', { error: message }); await restoreStashOnFailure(); // Check if it's a merge conflict if (message.includes('CONFLICT') || message.includes('conflict')) { return { success: false, error: 'Merge conflicts detected' }; } // Check for divergent branches error (shouldn't happen now but just in case) if (message.includes('divergent branches') || message.includes('Need to specify how to reconcile')) { return { success: false, error: 'Branches have diverged. This should have been handled automatically - please report this bug.' }; } return { success: false, error: message }; } }