/** * 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<void> { process.env.GIT_EDITOR = 'true'; await git.rebase(['--continue']);} export async function getConflictedFiles(git: SimpleGit): Promise<string[]> { const status = await git.status(); return status.conflicted || [];} export async function abortMerge(git: SimpleGit): Promise<void> { 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<void> { 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<void> { 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<MergeBaseResult> { 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/<baseBranch>, 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/<baseBranch> 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<void> { 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: <path>"). * 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<string> { 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 }; }}
/**
* 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<void> {
process.env.GIT_EDITOR = 'true';
await git.rebase(['--continue']);
}
export async function getConflictedFiles(git: SimpleGit): Promise<string[]> {
const status = await git.status();
return status.conflicted || [];
}
export async function abortMerge(git: SimpleGit): Promise<void> {
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<void> {
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<void> {
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<MergeBaseResult> {
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/<baseBranch>, 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/<baseBranch> 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<void> {
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: <path>").
* 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<string> {
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 };
}
}