| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import { createLogger, getErrorMessage } from '@automaker/utils'; |
| import { execGitCommand, getConflictFiles } from '@automaker/git-utils'; |
| import { execGitCommandWithLockRetry, getCurrentBranch } from '../lib/git.js'; |
|
|
| const logger = createLogger('PullService'); |
|
|
| |
| |
| |
|
|
| export interface PullOptions { |
| |
| remote?: string; |
| |
| remoteBranch?: string; |
| |
| stashIfNeeded?: boolean; |
| } |
|
|
| export interface PullResult { |
| success: boolean; |
| error?: string; |
| branch?: string; |
| pulled?: boolean; |
| hasLocalChanges?: boolean; |
| localChangedFiles?: string[]; |
| stashed?: boolean; |
| stashRestored?: boolean; |
| stashRecoveryFailed?: boolean; |
| hasConflicts?: boolean; |
| conflictSource?: 'pull' | 'stash'; |
| conflictFiles?: string[]; |
| message?: string; |
| |
| isMerge?: boolean; |
| |
| isFastForward?: boolean; |
| |
| mergeAffectedFiles?: string[]; |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| export async function fetchRemote(worktreePath: string, remote: string): Promise<void> { |
| await execGitCommand(['fetch', remote], worktreePath); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| export async function getLocalChanges( |
| worktreePath: string |
| ): Promise<{ hasLocalChanges: boolean; localChangedFiles: string[] }> { |
| const statusOutput = await execGitCommand(['status', '--porcelain'], worktreePath); |
| const hasLocalChanges = statusOutput.trim().length > 0; |
|
|
| let localChangedFiles: string[] = []; |
| if (hasLocalChanges) { |
| localChangedFiles = statusOutput |
| .trim() |
| .split('\n') |
| .filter((line) => line.trim().length > 0) |
| .map((line) => { |
| const entry = line.substring(3).trim(); |
| const arrowIndex = entry.indexOf(' -> '); |
| return arrowIndex !== -1 ? entry.substring(arrowIndex + 4).trim() : entry; |
| }); |
| } |
|
|
| return { hasLocalChanges, localChangedFiles }; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| export async function stashChanges(worktreePath: string, branchName: string): Promise<void> { |
| const stashMessage = `automaker-pull-stash: Pre-pull stash on ${branchName}`; |
| await execGitCommandWithLockRetry( |
| ['stash', 'push', '--include-untracked', '-m', stashMessage], |
| worktreePath |
| ); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| export async function popStash(worktreePath: string): Promise<string> { |
| return await execGitCommandWithLockRetry(['stash', 'pop'], worktreePath); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| async function tryPopStash(worktreePath: string): Promise<boolean> { |
| try { |
| await execGitCommandWithLockRetry(['stash', 'pop'], worktreePath); |
| return true; |
| } catch (stashPopError) { |
| |
| logger.error('Failed to reapply stash during error recovery', { |
| worktreePath, |
| error: getErrorMessage(stashPopError), |
| }); |
| return false; |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| export type UpstreamStatus = 'tracking' | 'remote' | 'none'; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export async function hasUpstreamOrRemoteBranch( |
| worktreePath: string, |
| branchName: string, |
| remote: string |
| ): Promise<UpstreamStatus> { |
| try { |
| await execGitCommand(['rev-parse', '--abbrev-ref', `${branchName}@{upstream}`], worktreePath); |
| return 'tracking'; |
| } catch { |
| |
| try { |
| await execGitCommand(['rev-parse', '--verify', `${remote}/${branchName}`], worktreePath); |
| return 'remote'; |
| } catch { |
| return 'none'; |
| } |
| } |
| } |
|
|
| |
| |
| |
| function isConflictError(errorOutput: string): boolean { |
| return errorOutput.includes('CONFLICT') || errorOutput.includes('Automatic merge failed'); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| async function isMergeCommit(worktreePath: string): Promise<boolean> { |
| try { |
| const output = await execGitCommand(['show', '-s', '--pretty=%P', 'HEAD'], worktreePath); |
| |
| const parents = output |
| .trim() |
| .split(/\s+/) |
| .filter((p) => p.length > 0); |
| return parents.length >= 2; |
| } catch { |
| |
| return false; |
| } |
| } |
|
|
| |
| |
| |
| function isStashConflict(output: string): boolean { |
| return output.includes('CONFLICT') || output.includes('Merge conflict'); |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export async function performPull( |
| worktreePath: string, |
| options?: PullOptions |
| ): Promise<PullResult> { |
| const targetRemote = options?.remote || 'origin'; |
| const stashIfNeeded = options?.stashIfNeeded ?? false; |
| const targetRemoteBranch = options?.remoteBranch; |
|
|
| |
| let branchName: string; |
| try { |
| branchName = await getCurrentBranch(worktreePath); |
| } catch (err) { |
| return { |
| success: false, |
| error: `Failed to get current branch: ${getErrorMessage(err)}`, |
| }; |
| } |
|
|
| |
| if (branchName === 'HEAD') { |
| return { |
| success: false, |
| error: 'Cannot pull in detached HEAD state. Please checkout a branch first.', |
| }; |
| } |
|
|
| |
| try { |
| await fetchRemote(worktreePath, targetRemote); |
| } catch (fetchError) { |
| return { |
| success: false, |
| error: `Failed to fetch from remote '${targetRemote}': ${getErrorMessage(fetchError)}`, |
| }; |
| } |
|
|
| |
| let hasLocalChanges: boolean; |
| let localChangedFiles: string[]; |
| try { |
| ({ hasLocalChanges, localChangedFiles } = await getLocalChanges(worktreePath)); |
| } catch (err) { |
| return { |
| success: false, |
| error: `Failed to get local changes: ${getErrorMessage(err)}`, |
| }; |
| } |
|
|
| |
| if (hasLocalChanges && !stashIfNeeded) { |
| return { |
| success: true, |
| branch: branchName, |
| pulled: false, |
| hasLocalChanges: true, |
| localChangedFiles, |
| message: |
| 'Local changes detected. Use stashIfNeeded to automatically stash and reapply changes.', |
| }; |
| } |
|
|
| |
| let didStash = false; |
| if (hasLocalChanges && stashIfNeeded) { |
| try { |
| await stashChanges(worktreePath, branchName); |
| didStash = true; |
| } catch (stashError) { |
| return { |
| success: false, |
| error: `Failed to stash local changes: ${getErrorMessage(stashError)}`, |
| }; |
| } |
| } |
|
|
| |
| |
| |
| let upstreamStatus: UpstreamStatus = 'tracking'; |
| if (!targetRemoteBranch) { |
| upstreamStatus = await hasUpstreamOrRemoteBranch(worktreePath, branchName, targetRemote); |
| if (upstreamStatus === 'none') { |
| let stashRecoveryFailed = false; |
| if (didStash) { |
| const stashPopped = await tryPopStash(worktreePath); |
| stashRecoveryFailed = !stashPopped; |
| } |
| return { |
| success: false, |
| error: `Branch '${branchName}' has no upstream branch on remote '${targetRemote}'. Push it first or set upstream with: git branch --set-upstream-to=${targetRemote}/${branchName}${stashRecoveryFailed ? ' Local changes remain stashed and need manual recovery (run: git stash pop).' : ''}`, |
| stashRecoveryFailed: stashRecoveryFailed ? stashRecoveryFailed : undefined, |
| }; |
| } |
| } |
|
|
| |
| |
| |
| |
| const pullArgs = targetRemoteBranch |
| ? ['pull', targetRemote, targetRemoteBranch] |
| : upstreamStatus === 'tracking' |
| ? ['pull'] |
| : ['pull', targetRemote, branchName]; |
| let pullConflict = false; |
| let pullConflictFiles: string[] = []; |
|
|
| |
| |
| let isMerge = false; |
| let isFastForward = false; |
| let mergeAffectedFiles: string[] = []; |
|
|
| try { |
| const pullOutput = await execGitCommand(pullArgs, worktreePath); |
|
|
| const alreadyUpToDate = pullOutput.includes('Already up to date'); |
| |
| isFastForward = pullOutput.includes('Fast-forward') || pullOutput.includes('fast-forward'); |
| |
| |
| isMerge = !alreadyUpToDate && !isFastForward ? await isMergeCommit(worktreePath) : false; |
|
|
| |
| if (isMerge) { |
| try { |
| |
| const diffOutput = await execGitCommand( |
| ['diff', '--name-only', 'HEAD~1', 'HEAD'], |
| worktreePath |
| ); |
| mergeAffectedFiles = diffOutput |
| .trim() |
| .split('\n') |
| .filter((f: string) => f.trim().length > 0); |
| } catch { |
| |
| } |
| } |
|
|
| |
| if (!didStash) { |
| return { |
| success: true, |
| branch: branchName, |
| pulled: !alreadyUpToDate, |
| hasLocalChanges: false, |
| stashed: false, |
| stashRestored: false, |
| message: alreadyUpToDate ? 'Already up to date' : 'Pulled latest changes', |
| ...(isMerge ? { isMerge: true, mergeAffectedFiles } : {}), |
| ...(isFastForward ? { isFastForward: true } : {}), |
| }; |
| } |
| } catch (pullError: unknown) { |
| const err = pullError as { stderr?: string; stdout?: string; message?: string }; |
| const errorOutput = `${err.stderr || ''} ${err.stdout || ''} ${err.message || ''}`; |
|
|
| if (isConflictError(errorOutput)) { |
| pullConflict = true; |
| try { |
| pullConflictFiles = await getConflictFiles(worktreePath); |
| } catch { |
| pullConflictFiles = []; |
| } |
| } else { |
| |
| let stashRecoveryFailed = false; |
| if (didStash) { |
| const stashPopped = await tryPopStash(worktreePath); |
| stashRecoveryFailed = !stashPopped; |
| } |
|
|
| |
| const errorMsg = err.stderr || err.message || 'Pull failed'; |
| if (errorMsg.includes('no tracking information')) { |
| return { |
| success: false, |
| error: `Branch '${branchName}' has no upstream branch. Push it first or set upstream with: git branch --set-upstream-to=${targetRemote}/${branchName}${stashRecoveryFailed ? ' Local changes remain stashed and need manual recovery (run: git stash pop).' : ''}`, |
| stashRecoveryFailed: stashRecoveryFailed ? stashRecoveryFailed : undefined, |
| }; |
| } |
|
|
| return { |
| success: false, |
| error: `${errorMsg}${stashRecoveryFailed ? ' Local changes remain stashed and need manual recovery (run: git stash pop).' : ''}`, |
| stashRecoveryFailed: stashRecoveryFailed ? stashRecoveryFailed : undefined, |
| }; |
| } |
| } |
|
|
| |
| if (pullConflict) { |
| return { |
| success: false, |
| branch: branchName, |
| pulled: true, |
| hasConflicts: true, |
| conflictSource: 'pull', |
| conflictFiles: pullConflictFiles, |
| stashed: didStash, |
| stashRestored: false, |
| message: |
| `Pull resulted in merge conflicts. ${didStash ? 'Your local changes are still stashed.' : ''}`.trim(), |
| }; |
| } |
|
|
| |
| if (didStash) { |
| return await reapplyStash(worktreePath, branchName, { |
| isMerge, |
| isFastForward, |
| mergeAffectedFiles, |
| }); |
| } |
|
|
| |
| return { |
| success: true, |
| branch: branchName, |
| pulled: true, |
| message: 'Pulled latest changes', |
| }; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| async function reapplyStash( |
| worktreePath: string, |
| branchName: string, |
| mergeInfo: { isMerge: boolean; isFastForward: boolean; mergeAffectedFiles: string[] } |
| ): Promise<PullResult> { |
| const mergeFields: Partial<PullResult> = { |
| ...(mergeInfo.isMerge |
| ? { isMerge: true, mergeAffectedFiles: mergeInfo.mergeAffectedFiles } |
| : {}), |
| ...(mergeInfo.isFastForward ? { isFastForward: true } : {}), |
| }; |
|
|
| try { |
| await popStash(worktreePath); |
|
|
| |
| return { |
| success: true, |
| branch: branchName, |
| pulled: true, |
| hasConflicts: false, |
| stashed: true, |
| stashRestored: true, |
| ...mergeFields, |
| message: 'Pulled latest changes and restored your stashed changes.', |
| }; |
| } catch (stashPopError: unknown) { |
| const err = stashPopError as { stderr?: string; stdout?: string; message?: string }; |
| const errorOutput = `${err.stderr || ''} ${err.stdout || ''} ${err.message || ''}`; |
|
|
| |
| |
| if (isStashConflict(errorOutput)) { |
| let stashConflictFiles: string[] = []; |
| try { |
| stashConflictFiles = await getConflictFiles(worktreePath); |
| } catch { |
| stashConflictFiles = []; |
| } |
|
|
| return { |
| success: true, |
| branch: branchName, |
| pulled: true, |
| hasConflicts: true, |
| conflictSource: 'stash', |
| conflictFiles: stashConflictFiles, |
| stashed: true, |
| stashRestored: false, |
| ...mergeFields, |
| message: 'Pull succeeded but reapplying your stashed changes resulted in merge conflicts.', |
| }; |
| } |
|
|
| |
| logger.warn('Failed to reapply stash after pull', { worktreePath, error: errorOutput }); |
|
|
| return { |
| success: true, |
| branch: branchName, |
| pulled: true, |
| hasConflicts: false, |
| stashed: true, |
| stashRestored: false, |
| ...mergeFields, |
| message: |
| 'Pull succeeded but failed to reapply stashed changes. Your changes are still in the stash list.', |
| }; |
| } |
| } |
|
|