/** * 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 }; }}
/**
* 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 };
}
}