Spaces:
Paused
Paused
| /** | |
| * PullService - Pull git operations without HTTP | |
| * | |
| * Encapsulates the full git pull workflow including: | |
| * - Branch name and detached HEAD detection | |
| * - Fetching from remote | |
| * - Status parsing and local change detection | |
| * - Stash push/pop logic | |
| * - Upstream verification (rev-parse / --verify) | |
| * - Pull execution and conflict detection | |
| * - Conflict file list collection | |
| * | |
| * Extracted from the worktree pull route to improve organization | |
| * and testability. Follows the same pattern as rebase-service.ts | |
| * and cherry-pick-service.ts. | |
| */ | |
| import { createLogger, getErrorMessage } from '@automaker/utils'; | |
| import { execGitCommand, getConflictFiles } from '@automaker/git-utils'; | |
| import { execGitCommandWithLockRetry, getCurrentBranch } from '../lib/git.js'; | |
| const logger = createLogger('PullService'); | |
| // ============================================================================ | |
| // Types | |
| // ============================================================================ | |
| export interface PullOptions { | |
| /** Remote name to pull from (defaults to 'origin') */ | |
| remote?: string; | |
| /** Specific remote branch to pull (e.g. 'main'). When provided, overrides the tracking branch and fetches this branch from the remote. */ | |
| remoteBranch?: string; | |
| /** When true, automatically stash local changes before pulling and reapply after */ | |
| 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; | |
| /** Whether the pull resulted in a merge commit (not fast-forward) */ | |
| isMerge?: boolean; | |
| /** Whether the pull was a fast-forward (no merge commit needed) */ | |
| isFastForward?: boolean; | |
| /** Files affected by the merge (only present when isMerge is true) */ | |
| mergeAffectedFiles?: string[]; | |
| } | |
| // ============================================================================ | |
| // Helper Functions | |
| // ============================================================================ | |
| /** | |
| * Fetch the latest refs from a remote. | |
| * | |
| * @param worktreePath - Path to the git worktree | |
| * @param remote - Remote name (e.g. 'origin') | |
| */ | |
| export async function fetchRemote(worktreePath: string, remote: string): Promise<void> { | |
| await execGitCommand(['fetch', remote], worktreePath); | |
| } | |
| /** | |
| * Parse `git status --porcelain` output into a list of changed file paths. | |
| * | |
| * @param worktreePath - Path to the git worktree | |
| * @returns Object with hasLocalChanges flag and list of changed file paths | |
| */ | |
| 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 }; | |
| } | |
| /** | |
| * Stash local changes with a descriptive message. | |
| * | |
| * @param worktreePath - Path to the git worktree | |
| * @param branchName - Current branch name (used in stash message) | |
| * @returns Promise<void> — resolves on success, throws on failure | |
| */ | |
| 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 | |
| ); | |
| } | |
| /** | |
| * Pop the top stash entry. | |
| * | |
| * @param worktreePath - Path to the git worktree | |
| * @returns The stdout from stash pop | |
| */ | |
| export async function popStash(worktreePath: string): Promise<string> { | |
| return await execGitCommandWithLockRetry(['stash', 'pop'], worktreePath); | |
| } | |
| /** | |
| * Try to pop the stash, returning whether the pop succeeded. | |
| * | |
| * @param worktreePath - Path to the git worktree | |
| * @returns true if stash pop succeeded, false if it failed | |
| */ | |
| async function tryPopStash(worktreePath: string): Promise<boolean> { | |
| try { | |
| await execGitCommandWithLockRetry(['stash', 'pop'], worktreePath); | |
| return true; | |
| } catch (stashPopError) { | |
| // Stash pop failed - leave it in stash list for manual recovery | |
| logger.error('Failed to reapply stash during error recovery', { | |
| worktreePath, | |
| error: getErrorMessage(stashPopError), | |
| }); | |
| return false; | |
| } | |
| } | |
| /** | |
| * Result of the upstream/remote branch check. | |
| * - 'tracking': the branch has a configured upstream tracking ref | |
| * - 'remote': no tracking ref, but the remote branch exists | |
| * - 'none': neither a tracking ref nor a remote branch was found | |
| */ | |
| export type UpstreamStatus = 'tracking' | 'remote' | 'none'; | |
| /** | |
| * Check whether the branch has an upstream tracking ref, or whether | |
| * the remote branch exists. | |
| * | |
| * @param worktreePath - Path to the git worktree | |
| * @param branchName - Current branch name | |
| * @param remote - Remote name | |
| * @returns UpstreamStatus indicating tracking ref, remote branch, or neither | |
| */ | |
| 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 { | |
| // No upstream tracking - check if the remote branch exists | |
| try { | |
| await execGitCommand(['rev-parse', '--verify', `${remote}/${branchName}`], worktreePath); | |
| return 'remote'; | |
| } catch { | |
| return 'none'; | |
| } | |
| } | |
| } | |
| /** | |
| * Check whether an error output string indicates a merge conflict. | |
| */ | |
| function isConflictError(errorOutput: string): boolean { | |
| return errorOutput.includes('CONFLICT') || errorOutput.includes('Automatic merge failed'); | |
| } | |
| /** | |
| * Determine whether the current HEAD commit is a merge commit by checking | |
| * whether it has two or more parent hashes. | |
| * | |
| * Runs `git show -s --pretty=%P HEAD` which prints the parent SHAs separated | |
| * by spaces. A merge commit has at least two parents; a regular commit has one. | |
| * | |
| * @param worktreePath - Path to the git worktree | |
| * @returns true if HEAD is a merge commit, false otherwise | |
| */ | |
| async function isMergeCommit(worktreePath: string): Promise<boolean> { | |
| try { | |
| const output = await execGitCommand(['show', '-s', '--pretty=%P', 'HEAD'], worktreePath); | |
| // Each parent SHA is separated by a space; two or more means it's a merge | |
| const parents = output | |
| .trim() | |
| .split(/\s+/) | |
| .filter((p) => p.length > 0); | |
| return parents.length >= 2; | |
| } catch { | |
| // If the check fails for any reason, assume it is not a merge commit | |
| return false; | |
| } | |
| } | |
| /** | |
| * Check whether an output string indicates a stash conflict. | |
| */ | |
| function isStashConflict(output: string): boolean { | |
| return output.includes('CONFLICT') || output.includes('Merge conflict'); | |
| } | |
| // ============================================================================ | |
| // Main Service Function | |
| // ============================================================================ | |
| /** | |
| * Perform a full git pull workflow on the given worktree. | |
| * | |
| * The workflow: | |
| * 1. Get current branch name (detect detached HEAD) | |
| * 2. Fetch from remote | |
| * 3. Check for local changes | |
| * 4. If local changes and stashIfNeeded, stash them | |
| * 5. Verify upstream tracking or remote branch exists | |
| * 6. Execute `git pull` | |
| * 7. If stash was created and pull succeeded, reapply stash | |
| * 8. Detect and report conflicts from pull or stash reapplication | |
| * | |
| * @param worktreePath - Path to the git worktree | |
| * @param options - Pull options (remote, stashIfNeeded) | |
| * @returns PullResult with detailed status information | |
| */ | |
| export async function performPull( | |
| worktreePath: string, | |
| options?: PullOptions | |
| ): Promise<PullResult> { | |
| const targetRemote = options?.remote || 'origin'; | |
| const stashIfNeeded = options?.stashIfNeeded ?? false; | |
| const targetRemoteBranch = options?.remoteBranch; | |
| // 1. Get current branch name | |
| let branchName: string; | |
| try { | |
| branchName = await getCurrentBranch(worktreePath); | |
| } catch (err) { | |
| return { | |
| success: false, | |
| error: `Failed to get current branch: ${getErrorMessage(err)}`, | |
| }; | |
| } | |
| // 2. Check for detached HEAD state | |
| if (branchName === 'HEAD') { | |
| return { | |
| success: false, | |
| error: 'Cannot pull in detached HEAD state. Please checkout a branch first.', | |
| }; | |
| } | |
| // 3. Fetch latest from remote | |
| try { | |
| await fetchRemote(worktreePath, targetRemote); | |
| } catch (fetchError) { | |
| return { | |
| success: false, | |
| error: `Failed to fetch from remote '${targetRemote}': ${getErrorMessage(fetchError)}`, | |
| }; | |
| } | |
| // 4. Check for local changes | |
| 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)}`, | |
| }; | |
| } | |
| // 5. If there are local changes and stashIfNeeded is not requested, return info | |
| 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.', | |
| }; | |
| } | |
| // 6. Stash local changes if needed | |
| 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)}`, | |
| }; | |
| } | |
| } | |
| // 7. Verify upstream tracking or remote branch exists | |
| // Skip this check when a specific remote branch is provided - we always use | |
| // explicit 'git pull <remote> <branch>' args in that case. | |
| 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, | |
| }; | |
| } | |
| } | |
| // 8. Pull latest changes | |
| // When a specific remote branch is requested, always use explicit remote + branch args. | |
| // When the branch has a configured upstream tracking ref, let Git use it automatically. | |
| // When only the remote branch exists (no tracking ref), explicitly specify remote and branch. | |
| const pullArgs = targetRemoteBranch | |
| ? ['pull', targetRemote, targetRemoteBranch] | |
| : upstreamStatus === 'tracking' | |
| ? ['pull'] | |
| : ['pull', targetRemote, branchName]; | |
| let pullConflict = false; | |
| let pullConflictFiles: string[] = []; | |
| // Declare merge detection variables before the try block so they are accessible | |
| // in the stash reapplication path even when didStash is true. | |
| let isMerge = false; | |
| let isFastForward = false; | |
| let mergeAffectedFiles: string[] = []; | |
| try { | |
| const pullOutput = await execGitCommand(pullArgs, worktreePath); | |
| const alreadyUpToDate = pullOutput.includes('Already up to date'); | |
| // Detect fast-forward from git pull output | |
| isFastForward = pullOutput.includes('Fast-forward') || pullOutput.includes('fast-forward'); | |
| // Detect merge by checking whether the new HEAD has two parents (more reliable | |
| // than string-matching localised pull output which may not contain 'Merge'). | |
| isMerge = !alreadyUpToDate && !isFastForward ? await isMergeCommit(worktreePath) : false; | |
| // If it was a real merge (not fast-forward), get the affected files | |
| if (isMerge) { | |
| try { | |
| // Get files changed in the merge commit | |
| const diffOutput = await execGitCommand( | |
| ['diff', '--name-only', 'HEAD~1', 'HEAD'], | |
| worktreePath | |
| ); | |
| mergeAffectedFiles = diffOutput | |
| .trim() | |
| .split('\n') | |
| .filter((f: string) => f.trim().length > 0); | |
| } catch { | |
| // Ignore errors - this is best-effort | |
| } | |
| } | |
| // If no stash to reapply, return success | |
| 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 { | |
| // Non-conflict pull error | |
| let stashRecoveryFailed = false; | |
| if (didStash) { | |
| const stashPopped = await tryPopStash(worktreePath); | |
| stashRecoveryFailed = !stashPopped; | |
| } | |
| // Check for common errors | |
| 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, | |
| }; | |
| } | |
| } | |
| // 9. If pull had conflicts, return conflict info (don't try stash pop) | |
| 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(), | |
| }; | |
| } | |
| // 10. Pull succeeded, now try to reapply stash | |
| if (didStash) { | |
| return await reapplyStash(worktreePath, branchName, { | |
| isMerge, | |
| isFastForward, | |
| mergeAffectedFiles, | |
| }); | |
| } | |
| // Shouldn't reach here, but return a safe default | |
| return { | |
| success: true, | |
| branch: branchName, | |
| pulled: true, | |
| message: 'Pulled latest changes', | |
| }; | |
| } | |
| /** | |
| * Attempt to reapply stashed changes after a successful pull. | |
| * Handles both clean reapplication and conflict scenarios. | |
| * | |
| * @param worktreePath - Path to the git worktree | |
| * @param branchName - Current branch name | |
| * @param mergeInfo - Merge/fast-forward detection info from the pull step | |
| * @returns PullResult reflecting stash reapplication status | |
| */ | |
| 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); | |
| // Stash pop succeeded cleanly (popStash throws on non-zero exit) | |
| 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 || ''}`; | |
| // Check if stash pop failed due to conflicts | |
| // The stash remains in the stash list when conflicts occur, so stashRestored is false | |
| 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.', | |
| }; | |
| } | |
| // Non-conflict stash pop error - stash is still in the stash list | |
| 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.', | |
| }; | |
| } | |
| } | |