/** * Git conflict checking and fetch-with-capture. * * **Workdir / cwd:** `fetchOriginBranch` resolves the directory from the bound **`SimpleGit`** instance * (`rev-parse --show-toplevel`) — that is the **PR clone checkout**, not necessarily `process.cwd()`. * Fallback `_baseDir || process.cwd()` is a last resort when rev-parse fails; prefer a correctly scoped git instance. * * WHY spawn instead of simple-git for fetch: simple-git's fetch() can hang indefinitely * on network or credential prompts; we need a timeout and to surface git's stdout/stderr * so users see e.g. "Password for 'https://...':" and can fix auth (token injection). * WHY one-shot auth URL for fetch: repos cloned without token in the URL prompt for * password; we have GITHUB_TOKEN in config—using it for fetch/pull avoids the prompt * without writing secrets to .git/config. */ import type { SimpleGit } from 'simple-git'; import { spawn, execFileSync, execFile } from 'child_process'; import { promisify } from 'util'; import { existsSync } from 'fs'; import { join } from 'path'; import { debug, formatNumber } from '../logger.js'; import { redactUrlCredentials } from './redact-url.js'; const execFileAsync = promisify(execFile); const DEFAULT_FETCH_TIMEOUT_MS = 60_000; /** * Parse `PRR_FETCH_TIMEOUT_MS` from env (default 60s, min 5s when valid). * Exported for tests; **`fetchOriginBranch`** uses a value parsed once at module load from **`process.env`**. */ export function parseFetchTimeoutMs(env: NodeJS.ProcessEnv = process.env): number { const raw = env.PRR_FETCH_TIMEOUT_MS; if (raw === undefined || raw === '') return DEFAULT_FETCH_TIMEOUT_MS; const n = parseInt(raw, 10); if (Number.isNaN(n)) { debug('PRR_FETCH_TIMEOUT_MS is set but not a valid integer; using default', { raw, defaultMs: formatNumber(DEFAULT_FETCH_TIMEOUT_MS), }); return DEFAULT_FETCH_TIMEOUT_MS; } return Math.max(5000, n); } /** Configurable via PRR_FETCH_TIMEOUT_MS (default 60s). Large repos or slow connections may need more. */ const FETCH_TIMEOUT_MS = parseFetchTimeoutMs(); /** * True when `branch` is safe to embed in `refs/heads/` for fetch refspec. * Uses `git check-ref-format --branch` when quick checks pass. */ export function isBranchRefSafeForOriginFetch(branch: string): boolean { if (!branch || branch.includes('..')) return false; if (/[\s\\~^:?*[\x00-\x1f\x7f]/.test(branch)) return false; try { execFileSync('git', ['check-ref-format', '--branch', branch], { stdio: ['ignore', 'ignore', 'ignore'] }); return true; } catch { return false; } } export interface FetchOptions { /** GitHub token for one-shot auth when remote URL has no credentials. Avoids password prompt. */ githubToken?: string; } /** * Run git fetch via spawn so we can capture stdout/stderr and show them on timeout. * When githubToken is provided and origin is HTTPS without credentials, uses one-shot * auth URL (same as push) so fetch does not prompt for password. */ export async function fetchOriginBranch( git: SimpleGit, branch: string, options?: FetchOptions ): Promise { let workdir: string; try { workdir = (await git.revparse(['--show-toplevel'])).trim(); } catch { workdir = (git as { _baseDir?: string })._baseDir || process.cwd(); debug('Fetch using fallback workdir', { workdir }); } const gitDir = join(workdir, '.git'); if (!existsSync(gitDir)) { throw new Error( `Resolved workdir "${workdir}" is not a git repository (no .git directory). ` + 'Fetch would run in the wrong directory. Check that the git instance is bound to a valid repo.' ); } // Ref names must pass `git check-ref-format --branch` (stricter than a lone regex; pill audit). // When using refspec we inject branch into refs/heads/...; invalid names produce a bad refspec or unsafe spawn args. const safeForRefspec = isBranchRefSafeForOriginFetch(branch); let args: string[]; try { const remoteUrl = execFileSync('git', ['remote', 'get-url', 'origin'], { cwd: workdir, encoding: 'utf8' }).trim(); const hasTokenInUrl = remoteUrl.includes('@') && remoteUrl.startsWith('https://'); if (safeForRefspec && !hasTokenInUrl && options?.githubToken && remoteUrl.startsWith('https://')) { const authUrl = remoteUrl.replace('https://', `https://${options.githubToken}@`); // WHY refspec: fetch updates refs/remotes/origin/branch so git.status() behind/ahead is correct. args = ['fetch', authUrl, `refs/heads/${branch}:refs/remotes/origin/${branch}`]; debug('Fetch with one-shot auth URL'); } else { let skipReason: string | undefined; if (!safeForRefspec) skipReason = 'branch ref not safe for embedded refspec'; else if (!remoteUrl.startsWith('https://')) skipReason = 'origin remote is not https'; else if (!options?.githubToken) skipReason = 'no githubToken in options'; else if (hasTokenInUrl) skipReason = 'remote URL already embeds credentials'; if (skipReason) { debug('Fetch using plain git fetch origin (one-shot auth not used)', { skipReason, branch }); } args = ['fetch', 'origin', branch]; } } catch (err) { debug('Fetch URL construction failed, falling back to plain fetch origin branch', { err: err instanceof Error ? err.message : String(err), branch, }); args = ['fetch', 'origin', branch]; } debug('Starting git fetch', { command: `git ${args.join(' ')}`, workdir }); return new Promise((resolve, reject) => { const proc = spawn('git', args, { cwd: workdir, stdio: ['ignore', 'pipe', 'pipe'], }); let stdout = ''; let stderr = ''; let settled = false; // WHY settle guard: timeout kills process then 'close' fires; we must resolve/reject only once. const settle = (fn: () => void) => { if (settled) return; settled = true; fn(); }; proc.stdout?.on('data', (d: Buffer) => { stdout += d.toString(); }); proc.stderr?.on('data', (d: Buffer) => { stderr += d.toString(); }); const timeout = setTimeout(() => { clearTimeout(timeout); proc.kill('SIGKILL'); settle(() => { const out = [ `Fetch timed out after ${formatNumber(Math.round(FETCH_TIMEOUT_MS / 1000))}s. Check network and remote access (origin/${branch}). Set PRR_FETCH_TIMEOUT_MS for slow connections.`, '', 'Output from git fetch:', stdout ? `stdout:\n${redactUrlCredentials(stdout)}` : '', stderr ? `stderr:\n${redactUrlCredentials(stderr)}` : '', ].filter(Boolean).join('\n'); reject(new Error(out)); }); }, FETCH_TIMEOUT_MS); proc.on('close', (code) => { clearTimeout(timeout); settle(() => { if (code === 0) { resolve(); } else { reject( new Error( `git fetch exited ${code}\nstdout:\n${redactUrlCredentials(stdout)}\nstderr:\n${redactUrlCredentials(stderr)}` ) ); } }); }); proc.on('error', (err) => { clearTimeout(timeout); settle(() => reject( new Error( `git fetch failed: ${redactUrlCredentials(err.message)}\nstderr: ${redactUrlCredentials(stderr)}` ) ) ); }); }); } export interface ConflictStatus { /** True when `git status` reports conflicted paths (in-progress merge/rebase). */ hasConflicts: boolean; conflictedFiles: string[]; behindBy: number; aheadBy: number; /** * True when `git merge-tree` predicts conflicts if `origin/` were merged into `HEAD`, * but the working tree is not yet in a conflicted merge state. */ latentConflictWithOrigin: boolean; /** Paths reported by the dry-merge probe (subset of files that would conflict). */ latentConflictedFiles: string[]; /** Set when the probe did not run (unsupported, disabled, or missing ref). */ latentProbeNote?: string; /** * True when `git merge-tree` predicts conflicts merging `origin/` into `HEAD` * (PR branch checkout vs PR base tip). Aligns with GitHub **mergeable / dirty** (vs PR↔remote-tip only). */ latentConflictWithPrBase: boolean; latentConflictedFilesWithPrBase: string[]; /** Set when the PR-base probe did not run (disabled, missing ref, or same as PR branch). */ latentProbePrBaseNote?: string; } async function resolveGitWorkdir(git: SimpleGit): Promise { try { return (await git.revparse(['--show-toplevel'])).trim(); } catch { return (git as { _baseDir?: string })._baseDir || process.cwd(); } } /** * Parse `git merge-tree` stderr/stdout for conflict paths. * Handles `Merge conflict in path` and `CONFLICT (type): path ...` (e.g. modify/delete). */ export function parseMergeTreeConflictPaths(combinedOutput: string): string[] { const files = new Set(); for (const m of combinedOutput.matchAll(/Merge conflict in (.+)$/gm)) { files.add(m[1].trim()); } for (const m of combinedOutput.matchAll(/^CONFLICT \([^)]+\):\s*(\S+)/gm)) { files.add(m[1].trim()); } return [...files]; } export interface LatentMergeProbeResult { ran: boolean; hasLatentConflicts: boolean; files: string[]; /** Why the probe did not run or could not conclude. */ skipReason?: string; } /** * Dry-merge `HEAD` with `origin/` using `git merge-tree` (Git 2.38+). * Does not modify the working tree or index. * * WHY: After `fetch`, `git status` does not show conflicts until a merge/rebase is in progress. * This surfaces the same conflicts GitHub may imply as "dirty" while local `hasConflicts` is false. * * Opt out: env named by **`options.disableEnvVar`** (default **`PRR_DISABLE_LATENT_MERGE_PROBE`**). * For the PR-base probe, use **`PRR_DISABLE_LATENT_MERGE_PROBE_BASE`**. */ export async function probeLatentMergeConflictsWithOrigin( git: SimpleGit, branch: string, options?: { disableEnvVar?: string } ): Promise { const envKey = options?.disableEnvVar ?? 'PRR_DISABLE_LATENT_MERGE_PROBE'; const disable = process.env[envKey]?.trim().toLowerCase(); if (disable === '1' || disable === 'true' || disable === 'yes') { return { ran: false, hasLatentConflicts: false, files: [], skipReason: envKey }; } const cwd = await resolveGitWorkdir(git); const remoteRef = `origin/${branch}`; const branchOk = branch.trim().length > 0 && !/[\s\\~^:?*[\x00-\x1f\x7f]/.test(branch) && !branch.includes('..'); if (!branchOk) { return { ran: false, hasLatentConflicts: false, files: [], skipReason: 'invalid branch name' }; } try { execFileSync('git', ['rev-parse', '--verify', remoteRef], { cwd, stdio: 'ignore' }); } catch { return { ran: false, hasLatentConflicts: false, files: [], skipReason: `missing ${remoteRef}` }; } let base: string; try { const { stdout } = await execFileAsync('git', ['merge-base', 'HEAD', remoteRef], { cwd }); base = stdout.trim(); if (!base) { return { ran: false, hasLatentConflicts: false, files: [], skipReason: 'empty merge-base' }; } } catch { return { ran: false, hasLatentConflicts: false, files: [], skipReason: 'merge-base failed' }; } try { await execFileAsync( 'git', ['merge-tree', '--name-only', '--write-tree', `--merge-base=${base}`, 'HEAD', remoteRef], { cwd, maxBuffer: 32 * 1024 * 1024 } ); return { ran: true, hasLatentConflicts: false, files: [] }; } catch (err: unknown) { const e = err as { stdout?: Buffer | string; stderr?: Buffer | string; code?: number }; const out = `${e.stdout?.toString?.() ?? e.stdout ?? ''}\n${e.stderr?.toString?.() ?? e.stderr ?? ''}`; const files = parseMergeTreeConflictPaths(out); return { ran: true, hasLatentConflicts: true, files }; } } /** * Check for merge conflicts and behind/ahead counts. WHY options.githubToken: unblocks fetch when remote has no credentials. * * **`hasConflicts` / `conflictedFiles`:** in-progress merge/rebase only (`git status`). * **`latentConflictWithOrigin`:** dry-merge `HEAD` vs `origin/` (PR head vs remote PR tip). * **`latentConflictWithPrBase`:** when **`options.prBaseBranch`** is set and differs from **`branch`**, second probe: * dry-merge `HEAD` vs `origin/` — closer to GitHub **mergeable / dirty** than PR-tip alone. */ export async function checkForConflicts( git: SimpleGit, branch: string, options?: FetchOptions & { prBaseBranch?: string } ): Promise { debug('Checking for conflicts', { branch, prBaseBranch: options?.prBaseBranch }); await fetchOriginBranch(git, branch, options); const status = await git.status(); // Check if there are merge conflicts (only when merge/rebase is in progress — not latent vs remote) const conflictedFiles = status.conflicted || []; // Check how far behind/ahead we are const behind = status.behind || 0; const ahead = status.ahead || 0; let latentConflictWithOrigin = false; let latentConflictedFiles: string[] = []; let latentProbeNote: string | undefined; let latentConflictWithPrBase = false; let latentConflictedFilesWithPrBase: string[] = []; let latentProbePrBaseNote: string | undefined; if (conflictedFiles.length === 0) { const probe = await probeLatentMergeConflictsWithOrigin(git, branch); if (probe.ran) { latentConflictWithOrigin = probe.hasLatentConflicts; latentConflictedFiles = probe.files; debug('Latent merge-tree probe', { branch, latentConflictWithOrigin, fileCount: probe.files.length, }); } else if (probe.skipReason) { latentProbeNote = probe.skipReason; debug('Latent merge-tree probe skipped', { branch, reason: probe.skipReason }); } const prBase = options?.prBaseBranch?.trim(); const branchTrim = branch.trim(); const shouldProbePrBase = Boolean(prBase && prBase !== branchTrim && prBase.length > 0); if (shouldProbePrBase && prBase) { try { await fetchOriginBranch(git, prBase, options); } catch (err) { debug('fetch for PR-base latent probe failed (probe may still use existing ref)', { prBase, err: err instanceof Error ? err.message : String(err), }); } const probeBase = await probeLatentMergeConflictsWithOrigin(git, prBase, { disableEnvVar: 'PRR_DISABLE_LATENT_MERGE_PROBE_BASE', }); if (probeBase.ran) { latentConflictWithPrBase = probeBase.hasLatentConflicts; latentConflictedFilesWithPrBase = probeBase.files; debug('Latent merge-tree probe (PR base)', { prBase, latentConflictWithPrBase, fileCount: probeBase.files.length, }); } else if (probeBase.skipReason) { latentProbePrBaseNote = probeBase.skipReason; debug('Latent merge-tree probe (PR base) skipped', { prBase, reason: probeBase.skipReason }); } } } debug('Conflict check result', { conflicted: conflictedFiles.length, behind, ahead, latent: latentConflictWithOrigin, latentFiles: latentConflictedFiles.length, latentPrBase: latentConflictWithPrBase, latentPrBaseFiles: latentConflictedFilesWithPrBase.length, }); return { hasConflicts: conflictedFiles.length > 0, conflictedFiles, behindBy: behind, aheadBy: ahead, latentConflictWithOrigin, latentConflictedFiles, latentProbeNote, latentConflictWithPrBase, latentConflictedFilesWithPrBase, latentProbePrBaseNote, }; } /** * Quick check if remote has new commits without full conflict detection. * * WHY: During fix iterations, we want to detect if someone pushed to the PR * so we can pull and re-verify instead of wasting cycles on stale code. * * @returns Number of commits we're behind, or 0 if up-to-date * WHY options.githubToken: same as checkForConflicts—fetch used during fix loop must not prompt for password. */ export async function checkRemoteAhead( git: SimpleGit, branch: string, options?: FetchOptions ): Promise<{ behind: number; ahead: number }> { debug('Quick check for remote commits', { branch }); await fetchOriginBranch(git, branch, options); const status = await git.status(); return { behind: status.behind || 0, ahead: status.ahead || 0, }; } /** * Pull latest changes from remote, handling divergent branches and local changes. * * WHY rebase: Keeps history clean. prr's commits should go on top of remote changes. * WHY auto-stash: Interrupted runs leave uncommitted changes that block pulls. * * Flow: * 1. Stash any uncommitted changes * 2. Fetch latest from remote * 3. If branches diverged, rebase local commits on top of remote * 4. Pop stash and handle any conflicts */