/** * Git merge operations */ import type { SimpleGit } from 'simple-git'; import { spawn } from 'child_process'; import { debug } from '../logger.js'; /** * Continue an in-progress rebase without opening an editor. * Sets GIT_EDITOR=true then runs git.rebase(['--continue']). * WHY: git rebase --continue invokes the configured editor (e.g. nano) to edit the * commit message. In non-interactive environments (prr workdir, CI) there is no TTY, * so the editor fails with "Standard input is not a terminal" or "problem with the * editor 'editor'. Please supply the message using -m or -F". GIT_EDITOR=true is a * no-op that exits 0, so git keeps the default (replayed) message. One helper for * all rebase --continue call sites keeps behavior consistent. */ export async function continueRebase(git: SimpleGit): Promise { process.env.GIT_EDITOR = 'true'; await git.rebase(['--continue']); } export async function getConflictedFiles(git: SimpleGit): Promise { const status = await git.status(); return status.conflicted || []; } export async function abortMerge(git: SimpleGit): Promise { try { await git.merge(['--abort']); } catch { // May fail if no merge in progress, ignore } } /** Run a git command in workdir with stdout/stderr suppressed so we don't flood the user with "fatal: No rebase in progress" etc. */ async function gitQuiet(workdir: string, args: string[]): Promise<{ exitCode: number | null }> { return new Promise((resolve) => { const proc = spawn('git', args, { cwd: workdir, stdio: ['ignore', 'ignore', 'ignore'], }); proc.on('close', (code) => resolve({ exitCode: code })); proc.on('error', () => resolve({ exitCode: null })); }); } export async function cleanupGitState(git: SimpleGit): Promise { debug('Cleaning up git state'); let workdir: string; try { workdir = (await git.revparse(['--show-toplevel'])).trim(); } catch { workdir = (git as { _baseDir?: string })._baseDir ?? process.cwd(); } // Run abort/reset/clean with stderr suppressed so user doesn't see "fatal: No merge in progress" etc. const m = await gitQuiet(workdir, ['merge', '--abort']); if (m.exitCode === 0) debug('Aborted in-progress merge'); const r = await gitQuiet(workdir, ['rebase', '--abort']); if (r.exitCode === 0) debug('Aborted in-progress rebase'); const c = await gitQuiet(workdir, ['cherry-pick', '--abort']); if (c.exitCode === 0) debug('Aborted in-progress cherry-pick'); const reset = await gitQuiet(workdir, ['reset', '--hard', 'HEAD']); if (reset.exitCode === 0) debug('Reset to HEAD'); const clean = await gitQuiet(workdir, ['clean', '-fd']); if (clean.exitCode === 0) debug('Cleaned untracked files'); } export interface MergeBaseResult { success: boolean; alreadyUpToDate?: boolean; conflictedFiles?: string[]; error?: string; } /** * Ensure git user.name and user.email are set in the repo so merge/commit don't fail with * "Committer identity unknown". In CI (GITHUB_ACTIONS) use the standard bot identity; * otherwise use a safe local default so PRR works without global git config. */ export async function ensureGitIdentity(git: SimpleGit): Promise { try { const name = await git.raw(['config', '--get', 'user.name']).then(s => s?.trim()); if (name && name.length > 0) return; } catch { // user.name not set } const inCI = process.env.GITHUB_ACTIONS === 'true'; const userName = inCI ? 'github-actions[bot]' : 'PRR'; const userEmail = inCI ? '41898282+github-actions[bot]@users.noreply.github.com' : 'prr@local'; await git.raw(['config', 'user.name', userName]); await git.raw(['config', 'user.email', userEmail]); debug('Set git identity for merge/commit', { userName, userEmail }); } export interface MergeBaseBranchOptions { /** When true, do not short-circuit with merge-base; always run git merge. Use when GitHub reports mergeableState === 'behind' so the source branch is actually updated with the target. */ forceMerge?: boolean; /** When true, use --no-ff so a merge commit is always created when there are incoming commits (never fast-forward). Ensures we have a commit to push and GitHub stops showing "out of date with base branch". */ noFastForward?: boolean; } export async function mergeBaseBranch( git: SimpleGit, baseBranch: string, options?: MergeBaseBranchOptions ): Promise { debug('Merging base branch into PR branch', { baseBranch, forceMerge: options?.forceMerge }); await ensureGitIdentity(git); try { // WHY explicit refspec: On --single-branch clones the default fetch config only // includes the PR branch; a plain fetch does not update origin/, so // the ref can be stale and the merge-base check incorrectly reports "already up-to-date". debug('Fetching base branch with explicit refspec', { baseBranch }); await git.raw(['remote', 'set-branches', '--add', 'origin', baseBranch]); await git.fetch(['origin', `+refs/heads/${baseBranch}:refs/remotes/origin/${baseBranch}`]); // Check if we're already up-to-date before trying merge (skip when forceMerge: GitHub said "behind") if (!options?.forceMerge) { const headSha = await git.revparse(['HEAD']); const baseSha = await git.revparse([`origin/${baseBranch}`]); const mergeBase = await git.raw(['merge-base', 'HEAD', `origin/${baseBranch}`]).then(s => s.trim()); if (baseSha.trim() === mergeBase) { debug('Already up-to-date with base branch'); return { success: true, alreadyUpToDate: true }; } } // Try to merge (--no-ff when requested so we always create a merge commit and have something to push) const mergeArgs: string[] = [`origin/${baseBranch}`, '--no-edit']; if (options?.noFastForward) mergeArgs.push('--no-ff'); const headBefore = (await git.revparse(['HEAD'])).trim(); debug('Attempting merge', { noFastForward: options?.noFastForward, headBefore: headBefore.slice(0, 10) }); const result = await git.merge(mergeArgs); const headAfter = (await git.revparse(['HEAD'])).trim(); debug('Merge completed', { headBefore: headBefore.slice(0, 10), headAfter: headAfter.slice(0, 10), headMoved: headBefore !== headAfter }); // Detect "Already up to date": simple-git's MergeResult (PullResult & MergeDetail) // does not store the "Already up to date" text in any parsed field — the line parsers // don't match it, so all fields stay at defaults (result: "success", files: [], etc.). // JSON.stringify therefore never contains "Already up to date", making the old string // check silently fail. Instead, compare HEAD before/after: if HEAD didn't move, the // merge was a no-op regardless of what simple-git reports. const resultStr = typeof result === 'string' ? result : JSON.stringify(result); const textSaysUpToDate = resultStr.includes('Already up to date') || resultStr.includes('Already up-to-date'); const headDidNotMove = headBefore === headAfter; if (textSaysUpToDate || headDidNotMove) { debug('Merge says already up-to-date', { textSaysUpToDate, headDidNotMove }); return { success: true, alreadyUpToDate: true }; } debug('Merge successful'); return { success: true, alreadyUpToDate: false }; } catch (error) { const message = error instanceof Error ? error.message : String(error); debug('Merge failed', { error: message }); // "Already up to date" can come as an error in some git versions if (message.includes('Already up to date') || message.includes('Already up-to-date')) { return { success: true, alreadyUpToDate: true }; } // Check for conflicts const status = await git.status(); if (status.conflicted && status.conflicted.length > 0) { await abortMerge(git); return { success: false, conflictedFiles: status.conflicted, error: 'Merge conflicts detected' }; } await abortMerge(git); return { success: false, error: message }; } } export async function startMergeForConflictResolution( git: SimpleGit, baseBranch: string, mergeMessage: string ): Promise<{ conflictedFiles: string[]; error?: string }> { debug('Starting merge for conflict resolution', { baseBranch }); try { // WHY explicit refspec: Same as mergeBaseBranch — ensure origin/ is // up-to-date so the merge we're about to start sees the real remote tip. try { await git.fetch(['origin', `+refs/heads/${baseBranch}:refs/remotes/origin/${baseBranch}`]); } catch { // May fail if branch doesn't exist on remote } // Start the merge (will fail with conflicts, that's expected). Only suppress conflict errors; // other failures (e.g. ref not found, permission) must propagate so callers don't assume conflicts. try { await git.merge([`origin/${baseBranch}`, '--no-commit']); } catch (err) { const msg = err instanceof Error ? err.message : String(err); const isConflict = /CONFLICT|conflict|Automatic merge failed|fix conflicts/i.test(msg); if (!isConflict) { debug('Merge failed (non-conflict), rethrowing', { error: msg }); throw err; } } // Get conflicted files const status = await git.status(); const conflictedFiles = status.conflicted || []; if (conflictedFiles.length === 0) { // No conflicts - either complete the merge or abort if nothing to merge if (!status.isClean()) { // Staged changes exist from merge - commit them const commitResult = await completeMerge(git, mergeMessage); if (!commitResult.success) { await abortMerge(git); return { conflictedFiles: [], error: commitResult.error || 'Failed to complete merge' }; } } else { // No changes and no conflicts - merge was a no-op, abort merge state await abortMerge(git); } } debug('Merge started, conflicts', { conflictedFiles }); return { conflictedFiles }; } catch (error) { const message = error instanceof Error ? error.message : String(error); return { conflictedFiles: [], error: message }; } } export async function markConflictsResolved(git: SimpleGit, files: string[]): Promise { debug('Marking conflicts as resolved', { files }); for (const file of files) { try { // Check if file exists in worktree before trying to add // WHY: Delete conflicts are resolved by git.rm() in resolveDeleteConflict(), // so the file no longer exists. Trying to git.add() a deleted file fails with // "pathspec did not match any files". Skip files that don't exist (they're // already staged for deletion). const { existsSync } = await import('fs'); const { join } = await import('path'); const workdir = (await git.revparse(['--show-toplevel'])).trim(); const fullPath = join(workdir, file); if (!existsSync(fullPath)) { debug('Skipping deleted file in markConflictsResolved', { file }); continue; } await git.add(file); } catch (err) { // If git.add fails (e.g. file was deleted), skip it // Delete conflicts are already handled by resolveDeleteConflict() debug('Skipping file in markConflictsResolved (likely deleted)', { file, error: err }); } } } /** * Resolve the real git directory (handles worktrees where .git is a file with "gitdir: "). * WHY: In worktrees, join(root, '.git') is a file; we must read it and resolve the path so * existsSync(rebase-merge) works. Shared by completeMerge and repository.ts pull conflict loop. */ export async function getResolvedGitDir(git: SimpleGit): Promise { const { readFileSync, statSync } = await import('fs'); const { join, resolve: pathResolve, dirname } = await import('path'); const root = await git.revparse(['--show-toplevel']).catch(() => null); const gitDirRaw = root ? join(root.trim(), '.git') : (await git.revparse(['--git-dir'])).trim(); const rootDir = root ? pathResolve(root.trim()) : null; try { const stat = statSync(gitDirRaw); if (stat.isFile()) { const content = readFileSync(gitDirRaw, 'utf-8'); const m = content.match(/^gitdir:\s*(.+)$/m); const target = m ? m[1].trim() : null; const base = rootDir ?? pathResolve(dirname(gitDirRaw)); return target ? pathResolve(base, target) : pathResolve(gitDirRaw); } return pathResolve(gitDirRaw); } catch { return pathResolve(gitDirRaw); } } /** * Complete an in-progress merge or rebase after conflicts were resolved. * WHY this exists: After resolveConflicts() we've staged the resolution; we must either * `rebase --continue` (rebase) or `commit` (merge). Using the wrong one leaves .git/rebase-merge * behind and breaks the next push retry. */ export async function completeMerge(git: SimpleGit, message: string): Promise<{ success: boolean; error?: string }> { debug('Completing merge commit'); try { const { existsSync } = await import('fs'); const { join } = await import('path'); const resolvedGitDir = await getResolvedGitDir(git); const inRebase = existsSync(join(resolvedGitDir, 'rebase-merge')) || existsSync(join(resolvedGitDir, 'rebase-apply')); if (inRebase) { debug('In rebase - continuing rebase'); await continueRebase(git); } else { debug('In merge - committing'); await git.commit(message); } return { success: true }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); return { success: false, error: errorMessage }; } }