/** * Git clone and update operations * * WHY clone/fetch via spawn with stdio inherit: simple-git's clone()/fetch() often don't * surface progress (e.g. "Receiving objects: 45%"); the same outputHandler was applied to * every subsequent git call (diff, status, etc.) so users saw diffs instead of clone progress. * We run only clone and the main fetch via spawn so the user sees those; the returned git * instance has no outputHandler so the rest of the run (diffs, status, commit) stays quiet. */import { simpleGit, type SimpleGit } from 'simple-git';import { spawn } from 'child_process';import { existsSync, readdirSync, readFileSync, statSync } from 'fs';import { join, dirname } from 'path';import { debug } from '../logger.js';import { DEFAULT_CLONE_TIMEOUT_MS } from '../constants.js';import { cleanupGitState } from './git-merge.js'; /** Normalize clone URL for comparison: strip credentials and trailing .git so same repo matches. */function normalizeCloneUrl(url: string): string { let u = url.trim(); // Strip token: https://token@host/path -> https://host/path u = u.replace(/^(https?:\/\/)[^@]+@/, '$1'); if (u.endsWith('.git')) u = u.slice(0, -4); return u.toLowerCase();} /** Read origin URL from workdir's .git/config (no spawn). Returns null if unreadable or no origin. */function getOriginFromWorkdir(workdirPath: string): string | null { const configPath = join(workdirPath, '.git', 'config'); if (!existsSync(configPath)) return null; try { const content = readFileSync(configPath, 'utf8'); const match = content.match(/\[remote\s+"origin"\][\s\S]*?url\s*=\s*(.+?)(?:\r?\n|$)/im); if (!match) return null; return match[1].trim(); } catch { return null; }} /** Mtime of .git/FETCH_HEAD or .git for "most recently fetched" ordering. */function getFetchHeadMtime(workdirPath: string): number { const fetchHead = join(workdirPath, '.git', 'FETCH_HEAD'); if (existsSync(fetchHead)) return statSync(fetchHead).mtimeMs; const gitDir = join(workdirPath, '.git'); return existsSync(gitDir) ? statSync(gitDir).mtimeMs : 0;} /** * Find another workdir for the same repo to use as git clone --reference (bootstrap from local copy). * WHY: Git is distributed; reusing an existing clone only fetches missing objects and is much faster. * Scans sibling dirs of workdir (e.g. ~/.prr/work/*), picks one with same origin and latest fetch. */function findReferenceWorkdir(workdir: string, cloneUrl: string): string | null { const baseDir = dirname(workdir); const wantNorm = normalizeCloneUrl(cloneUrl); const candidates: { path: string; mtime: number }[] = []; try { const names = readdirSync(baseDir, { withFileTypes: true }); for (const ent of names) { if (!ent.isDirectory()) continue; const candidatePath = join(baseDir, ent.name); if (candidatePath === workdir) continue; const gitDir = join(candidatePath, '.git'); if (!existsSync(gitDir)) continue; const origin = getOriginFromWorkdir(candidatePath); if (origin == null) continue; if (normalizeCloneUrl(origin) !== wantNorm) continue; const mtime = getFetchHeadMtime(candidatePath); candidates.push({ path: candidatePath, mtime }); } } catch { return null; } if (candidates.length === 0) return null; candidates.sort((a, b) => b.mtime - a.mtime); return candidates[0].path;} /** * Ensure refs/remotes/origin/<b> exist for each additional branch after fetch. * WHY: --single-branch clones omit base from default fetch; a missing origin/base makes * merge-base checks lie ("already up-to-date") while the PR is dirty (AGENTS.md). */async function assertAdditionalBranchTrackingRefs( git: SimpleGit, primaryBranch: string, additionalBranches: string[] | undefined): Promise<void> { if (!additionalBranches?.length) return; const missing: string[] = []; for (const b of additionalBranches) { if (!b || b === primaryBranch) continue; try { await git.raw(['rev-parse', '--verify', `refs/remotes/origin/${b}`]); } catch { missing.push(b); } } if (missing.length === 0) return; const list = missing.map((b) => `origin/${b}`).join(', '); throw new Error( `Missing remote tracking ref(s) after fetch: ${list}. ` + `Those branches may not exist on the remote, or fetching them failed. ` + `PRR needs these refs for base-branch merge checks. Fix the branch name or ensure it exists on origin, then re-run.`, );} /** * Replace all `remote.origin.fetch` entries with clean refspecs for the requested branches. * WHY direct config edit: `git remote set-branches` validates existing entries before replacing; * if any stale refspec is invalid (e.g. branch name with `:` from old split-exec runs), the command * itself fails. We remove all entries first via `--unset-all`, then add only the ones we need. */async function syncOriginFetchBranches( git: SimpleGit, primaryBranch: string, additionalBranches: string[] | undefined): Promise<void> { // Remove all existing fetch refspecs (tolerate "key does not exist" on fresh clones). try { await git.raw(['config', '--unset-all', 'remote.origin.fetch']); } catch { // Key may not exist — that's fine. } // Build the set of branches we actually need. const names = new Set<string>(); const p = primaryBranch?.trim(); if (p) names.add(p); for (const b of additionalBranches ?? []) { const t = typeof b === 'string' ? b.trim() : ''; if (t) names.add(t); } // Add one refspec per branch. for (const name of names) { await git.raw(['config', '--add', 'remote.origin.fetch', `+refs/heads/${name}:refs/remotes/origin/${name}`]); } debug('syncOriginFetchBranches', { branches: [...names] });} /** Fetch extra branches (explicit refspec) so origin/<b> exists under --single-branch. */async function fetchAdditionalBranches(git: SimpleGit, primaryBranch: string, additionalBranches: string[] | undefined): Promise<void> { await syncOriginFetchBranches(git, primaryBranch, additionalBranches); if (!additionalBranches?.length) return; for (const b of additionalBranches) { if (b && b !== primaryBranch) { try { debug('Fetching additional branch', { branch: b }); await git.fetch(['origin', `+refs/heads/${b}:refs/remotes/origin/${b}`]); } catch (err) { const msg = err instanceof Error ? err.message : String(err); debug(`Failed to fetch origin/${b}`, { err: msg }); const isBranchMissing = /couldn't find|does not exist|not found|invalid refspec/i.test(msg); if (isBranchMissing) { console.warn(` ⚠ Branch ${b} does not exist on remote; ref origin/${b} will be missing.`); } else { console.warn(` ⚠ Failed to fetch origin/${b}: ${msg.slice(0, 80)}${msg.length > 80 ? '…' : ''}`); } } } }} /** Clone timeout in ms. Override with PRR_CLONE_TIMEOUT_MS (default 900s). */function getCloneTimeoutMs(): number { const raw = process.env.PRR_CLONE_TIMEOUT_MS; if (raw === undefined || raw === '') return DEFAULT_CLONE_TIMEOUT_MS; const n = parseInt(raw, 10); return Number.isFinite(n) && n >= 5000 ? n : DEFAULT_CLONE_TIMEOUT_MS;} /** Optional shallow clone: set PRR_CLONE_DEPTH to a positive integer (e.g. 1) for faster large-repo clones; full history otherwise. */function getCloneDepthArg(): string[] { const raw = process.env.PRR_CLONE_DEPTH?.trim(); if (raw === undefined || raw === '') return []; const n = parseInt(raw, 10); if (!Number.isFinite(n) || n < 1) return []; return ['--depth', String(n)];} export interface GitOperations { git: SimpleGit; workdir: string;} export interface CloneOptions { preserveChanges?: boolean; // If true, don't reset - keep existing uncommitted changes /** Fetch these branches after clone/update so refs exist (e.g. split-exec needs origin/targetBranch). */ additionalBranches?: string[]; /** * When true (default), require every `additionalBranches` entry (except the primary clone branch) * to exist as `refs/remotes/origin/<name>` after fetch — fail fast if missing. * WHY: PRR base-branch merge needs real refs. split-exec lists new split branches that may not * exist on the remote yet; pass `verifyAdditionalRemoteRefs: false` there. */ verifyAdditionalRemoteRefs?: boolean;} export async function cloneOrUpdate( cloneUrl: string, branch: string, workdir: string, githubToken?: string, options?: CloneOptions): Promise<GitOperations> { // Inject token into clone URL for authentication let authUrl = cloneUrl; if (githubToken && cloneUrl.startsWith('https://')) { authUrl = cloneUrl.replace('https://', `https://${githubToken}@`); } const gitDir = join(workdir, '.git'); let isExistingRepo = existsSync(gitDir); let git: SimpleGit | undefined; if (isExistingRepo) { git = simpleGit(workdir); let repoUsable = false; try { await git.raw(['rev-parse', 'HEAD']); repoUsable = true; } catch { debug('Workdir has .git but no valid HEAD (broken or incomplete clone), will clone fresh'); } if (!repoUsable) { const { rm } = await import('fs/promises'); await rm(workdir, { recursive: true, force: true }); isExistingRepo = false; console.log(' Workdir incomplete or broken, cloning fresh...'); } } if (isExistingRepo) { // Sanitize remote.origin.fetch FIRST — stale invalid refspecs (e.g. branch names with `:` from old // split-exec `set-branches --add` runs) make ANY `git remote` subcommand fail, including `set-url`. await syncOriginFetchBranches(git!, branch, options?.additionalBranches); // Set remote to auth URL when we have a token so fetch/pull/push do not prompt if (githubToken && cloneUrl.startsWith('https://')) { await git!.raw(['remote', 'set-url', 'origin', authUrl]); debug('Set origin remote URL with token so fetch/pull/push do not prompt', { tokenLength: githubToken.length, }); } else if (!githubToken) { debug('No GitHub token provided - fetch/push may prompt for credentials'); } if (options?.preserveChanges) { // Preserve existing changes - just make sure we're on the right branch console.log(`Existing workdir found at ${workdir}, preserving local changes...`); // Abort any stuck rebase/merge/cherry-pick from a previous failed run. // Without this, a prior crash mid-rebase leaves the workdir in an // unusable state and every subsequent run fails at the same point. const rebaseMerge = join(workdir, '.git', 'rebase-merge'); const rebaseApply = join(workdir, '.git', 'rebase-apply'); const mergeHead = join(workdir, '.git', 'MERGE_HEAD'); const cherryPickHead = join(workdir, '.git', 'CHERRY_PICK_HEAD'); if (existsSync(rebaseMerge) || existsSync(rebaseApply) || existsSync(mergeHead) || existsSync(cherryPickHead)) { console.log(' ⚠ Detected stuck rebase/merge from previous run, aborting...'); try { await git!.rebase(['--abort']); } catch { /* no rebase */ } try { await git!.merge(['--abort']); } catch { /* no merge */ } try { await git!.raw(['cherry-pick', '--abort']); } catch { /* no cherry-pick */ } debug('Aborted stuck git operation in preserveChanges path'); } const status = await git!.status(); const hasChanges = status.modified.length > 0 || status.created.length > 0 || status.staged.length > 0; if (hasChanges) { console.log(` Keeping ${status.modified.length + status.created.length} modified files`); } // Just ensure we're on the right branch, don't reset try { await git!.checkout(branch); } catch { // Already on branch or changes prevent checkout - that's fine } // WHY: Base-branch ref must exist for merge-base even when we preserve dirty workdir; // the non-preserve path always fetched these — align behavior (fetch-only, no reset). await fetchAdditionalBranches(git!, branch, options?.additionalBranches); } else { // Clean start - reset everything console.log(`Existing workdir found at ${workdir}, cleaning up and fetching latest...`); // Clean up any leftover merge/rebase state from previous runs (includes git clean -fd) await cleanupGitState(git!); // Ensure we're on a branch before reset (e.g. previous run left detached HEAD mid-rebase). await git!.checkout(branch).catch(() => {}); await syncOriginFetchBranches(git!, branch, options?.additionalBranches); // Run fetch via spawn with stdio inherit so user sees "Receiving objects" progress (not diffs). console.log(' Fetching latest from origin (git output will appear below)...'); await new Promise<void>((resolve, reject) => { const proc = spawn('git', ['fetch', 'origin', `+refs/heads/${branch}:refs/remotes/origin/${branch}`], { cwd: workdir, stdio: 'inherit', }); proc.on('close', (code) => { if (code === 0) resolve(); else reject(new Error(`git fetch exited ${code ?? 'unknown'}`)); }); proc.on('error', (err) => reject(err)); }); try { await git!.checkout(branch); } catch { // Local branch may not exist (e.g. --single-branch clone for a different branch); create from remote. await git!.raw(['checkout', '-b', branch, `origin/${branch}`]); } await git!.reset(['--hard', `origin/${branch}`]); await fetchAdditionalBranches(git!, branch, options?.additionalBranches); console.log(`Updated to latest ${branch}`); } } if (!isExistingRepo) { // Fresh clone. Bootstrap from another workdir for same repo when possible (git is distributed). const referencePath = findReferenceWorkdir(workdir, cloneUrl); const cloneArgs = ['clone', '--branch', branch, '--single-branch', ...getCloneDepthArg()]; if (referencePath) { cloneArgs.push('--reference', referencePath); debug('Using reference workdir for clone', { referencePath, workdir }); console.log(` Bootstrapping from existing clone (${referencePath})...`); } const cloneTimeoutMs = getCloneTimeoutMs(); const depthNote = process.env.PRR_CLONE_DEPTH?.trim() ? ` shallow depth=${process.env.PRR_CLONE_DEPTH.trim()};` : ''; console.log(`Cloning repository to ${workdir}...`); console.log( ` (Git output and any prompts will appear below. Timeout: ${Math.round(cloneTimeoutMs / 1000)}s; PRR_CLONE_TIMEOUT_MS for slow connections.${depthNote} PRR_CLONE_DEPTH for shallow clone.)`, ); await new Promise<void>((resolve, reject) => { const proc = spawn('git', [...cloneArgs, authUrl, workdir], { stdio: 'inherit', }); let settled = false; const settle = (fn: () => void) => { if (settled) return; settled = true; clearTimeout(timeoutId); clearInterval(progressTimer); fn(); }; let timeoutId: ReturnType<typeof setTimeout>; timeoutId = setTimeout(() => { proc.kill('SIGKILL'); settle(() => reject( new Error( `Clone timed out after ${Math.round(cloneTimeoutMs / 1000)}s. Set PRR_CLONE_TIMEOUT_MS to increase (e.g. 600000 for 10min).`, ), ), ); }, cloneTimeoutMs); const progressIntervalMs = 30_000; let progressElapsed = 0; const progressTimer = setInterval(() => { progressElapsed += progressIntervalMs / 1000; console.log(` Still cloning... (${Math.floor(progressElapsed)}s)`); }, progressIntervalMs); proc.on('close', (code) => { settle(() => { if (code === 0) resolve(); else reject(new Error(`git clone exited ${code ?? 'unknown'}`)); }); }); proc.on('error', (err) => { settle(() => reject(err)); }); }); git = simpleGit(workdir); await fetchAdditionalBranches(git, branch, options?.additionalBranches); console.log(`Cloned ${branch} successfully`); } if (git === undefined) { throw new Error('cloneOrUpdate: internal error — git instance was not initialized'); } const verifyRefs = options?.verifyAdditionalRemoteRefs !== false; if (verifyRefs) { await assertAdditionalBranchTrackingRefs(git, branch, options?.additionalBranches); } return { git, workdir };}
/**
* Git clone and update operations
*
* WHY clone/fetch via spawn with stdio inherit: simple-git's clone()/fetch() often don't
* surface progress (e.g. "Receiving objects: 45%"); the same outputHandler was applied to
* every subsequent git call (diff, status, etc.) so users saw diffs instead of clone progress.
* We run only clone and the main fetch via spawn so the user sees those; the returned git
* instance has no outputHandler so the rest of the run (diffs, status, commit) stays quiet.
*/
import { simpleGit, type SimpleGit } from 'simple-git';
import { spawn } from 'child_process';
import { existsSync, readdirSync, readFileSync, statSync } from 'fs';
import { join, dirname } from 'path';
import { debug } from '../logger.js';
import { DEFAULT_CLONE_TIMEOUT_MS } from '../constants.js';
import { cleanupGitState } from './git-merge.js';
/** Normalize clone URL for comparison: strip credentials and trailing .git so same repo matches. */
function normalizeCloneUrl(url: string): string {
let u = url.trim();
// Strip token: https://token@host/path -> https://host/path
u = u.replace(/^(https?:\/\/)[^@]+@/, '$1');
if (u.endsWith('.git')) u = u.slice(0, -4);
return u.toLowerCase();
}
/** Read origin URL from workdir's .git/config (no spawn). Returns null if unreadable or no origin. */
function getOriginFromWorkdir(workdirPath: string): string | null {
const configPath = join(workdirPath, '.git', 'config');
if (!existsSync(configPath)) return null;
try {
const content = readFileSync(configPath, 'utf8');
const match = content.match(/\[remote\s+"origin"\][\s\S]*?url\s*=\s*(.+?)(?:\r?\n|$)/im);
if (!match) return null;
return match[1].trim();
} catch {
return null;
}
}
/** Mtime of .git/FETCH_HEAD or .git for "most recently fetched" ordering. */
function getFetchHeadMtime(workdirPath: string): number {
const fetchHead = join(workdirPath, '.git', 'FETCH_HEAD');
if (existsSync(fetchHead)) return statSync(fetchHead).mtimeMs;
const gitDir = join(workdirPath, '.git');
return existsSync(gitDir) ? statSync(gitDir).mtimeMs : 0;
}
/**
* Find another workdir for the same repo to use as git clone --reference (bootstrap from local copy).
* WHY: Git is distributed; reusing an existing clone only fetches missing objects and is much faster.
* Scans sibling dirs of workdir (e.g. ~/.prr/work/*), picks one with same origin and latest fetch.
*/
function findReferenceWorkdir(workdir: string, cloneUrl: string): string | null {
const baseDir = dirname(workdir);
const wantNorm = normalizeCloneUrl(cloneUrl);
const candidates: { path: string; mtime: number }[] = [];
try {
const names = readdirSync(baseDir, { withFileTypes: true });
for (const ent of names) {
if (!ent.isDirectory()) continue;
const candidatePath = join(baseDir, ent.name);
if (candidatePath === workdir) continue;
const gitDir = join(candidatePath, '.git');
if (!existsSync(gitDir)) continue;
const origin = getOriginFromWorkdir(candidatePath);
if (origin == null) continue;
if (normalizeCloneUrl(origin) !== wantNorm) continue;
const mtime = getFetchHeadMtime(candidatePath);
candidates.push({ path: candidatePath, mtime });
}
} catch {
return null;
}
if (candidates.length === 0) return null;
candidates.sort((a, b) => b.mtime - a.mtime);
return candidates[0].path;
}
/**
* Ensure refs/remotes/origin/<b> exist for each additional branch after fetch.
* WHY: --single-branch clones omit base from default fetch; a missing origin/base makes
* merge-base checks lie ("already up-to-date") while the PR is dirty (AGENTS.md).
*/
async function assertAdditionalBranchTrackingRefs(
git: SimpleGit,
primaryBranch: string,
additionalBranches: string[] | undefined
): Promise<void> {
if (!additionalBranches?.length) return;
const missing: string[] = [];
for (const b of additionalBranches) {
if (!b || b === primaryBranch) continue;
try {
await git.raw(['rev-parse', '--verify', `refs/remotes/origin/${b}`]);
} catch {
missing.push(b);
}
}
if (missing.length === 0) return;
const list = missing.map((b) => `origin/${b}`).join(', ');
throw new Error(
`Missing remote tracking ref(s) after fetch: ${list}. ` +
`Those branches may not exist on the remote, or fetching them failed. ` +
`PRR needs these refs for base-branch merge checks. Fix the branch name or ensure it exists on origin, then re-run.`,
);
}
/**
* Replace all `remote.origin.fetch` entries with clean refspecs for the requested branches.
* WHY direct config edit: `git remote set-branches` validates existing entries before replacing;
* if any stale refspec is invalid (e.g. branch name with `:` from old split-exec runs), the command
* itself fails. We remove all entries first via `--unset-all`, then add only the ones we need.
*/
async function syncOriginFetchBranches(
git: SimpleGit,
primaryBranch: string,
additionalBranches: string[] | undefined
): Promise<void> {
// Remove all existing fetch refspecs (tolerate "key does not exist" on fresh clones).
try {
await git.raw(['config', '--unset-all', 'remote.origin.fetch']);
} catch {
// Key may not exist — that's fine.
}
// Build the set of branches we actually need.
const names = new Set<string>();
const p = primaryBranch?.trim();
if (p) names.add(p);
for (const b of additionalBranches ?? []) {
const t = typeof b === 'string' ? b.trim() : '';
if (t) names.add(t);
}
// Add one refspec per branch.
for (const name of names) {
await git.raw(['config', '--add', 'remote.origin.fetch', `+refs/heads/${name}:refs/remotes/origin/${name}`]);
}
debug('syncOriginFetchBranches', { branches: [...names] });
}
/** Fetch extra branches (explicit refspec) so origin/<b> exists under --single-branch. */
async function fetchAdditionalBranches(git: SimpleGit, primaryBranch: string, additionalBranches: string[] | undefined): Promise<void> {
await syncOriginFetchBranches(git, primaryBranch, additionalBranches);
if (!additionalBranches?.length) return;
for (const b of additionalBranches) {
if (b && b !== primaryBranch) {
try {
debug('Fetching additional branch', { branch: b });
await git.fetch(['origin', `+refs/heads/${b}:refs/remotes/origin/${b}`]);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
debug(`Failed to fetch origin/${b}`, { err: msg });
const isBranchMissing = /couldn't find|does not exist|not found|invalid refspec/i.test(msg);
if (isBranchMissing) {
console.warn(` ⚠ Branch ${b} does not exist on remote; ref origin/${b} will be missing.`);
} else {
console.warn(` ⚠ Failed to fetch origin/${b}: ${msg.slice(0, 80)}${msg.length > 80 ? '…' : ''}`);
}
}
}
}
}
/** Clone timeout in ms. Override with PRR_CLONE_TIMEOUT_MS (default 900s). */
function getCloneTimeoutMs(): number {
const raw = process.env.PRR_CLONE_TIMEOUT_MS;
if (raw === undefined || raw === '') return DEFAULT_CLONE_TIMEOUT_MS;
const n = parseInt(raw, 10);
return Number.isFinite(n) && n >= 5000 ? n : DEFAULT_CLONE_TIMEOUT_MS;
}
/** Optional shallow clone: set PRR_CLONE_DEPTH to a positive integer (e.g. 1) for faster large-repo clones; full history otherwise. */
function getCloneDepthArg(): string[] {
const raw = process.env.PRR_CLONE_DEPTH?.trim();
if (raw === undefined || raw === '') return [];
const n = parseInt(raw, 10);
if (!Number.isFinite(n) || n < 1) return [];
return ['--depth', String(n)];
}
export interface GitOperations {
git: SimpleGit;
workdir: string;
}
export interface CloneOptions {
preserveChanges?: boolean; // If true, don't reset - keep existing uncommitted changes
/** Fetch these branches after clone/update so refs exist (e.g. split-exec needs origin/targetBranch). */
additionalBranches?: string[];
/**
* When true (default), require every `additionalBranches` entry (except the primary clone branch)
* to exist as `refs/remotes/origin/<name>` after fetch — fail fast if missing.
* WHY: PRR base-branch merge needs real refs. split-exec lists new split branches that may not
* exist on the remote yet; pass `verifyAdditionalRemoteRefs: false` there.
*/
verifyAdditionalRemoteRefs?: boolean;
}
export async function cloneOrUpdate(
cloneUrl: string,
branch: string,
workdir: string,
githubToken?: string,
options?: CloneOptions
): Promise<GitOperations> {
// Inject token into clone URL for authentication
let authUrl = cloneUrl;
if (githubToken && cloneUrl.startsWith('https://')) {
authUrl = cloneUrl.replace('https://', `https://${githubToken}@`);
}
const gitDir = join(workdir, '.git');
let isExistingRepo = existsSync(gitDir);
let git: SimpleGit | undefined;
if (isExistingRepo) {
git = simpleGit(workdir);
let repoUsable = false;
try {
await git.raw(['rev-parse', 'HEAD']);
repoUsable = true;
} catch {
debug('Workdir has .git but no valid HEAD (broken or incomplete clone), will clone fresh');
}
if (!repoUsable) {
const { rm } = await import('fs/promises');
await rm(workdir, { recursive: true, force: true });
isExistingRepo = false;
console.log(' Workdir incomplete or broken, cloning fresh...');
}
}
if (isExistingRepo) {
// Sanitize remote.origin.fetch FIRST — stale invalid refspecs (e.g. branch names with `:` from old
// split-exec `set-branches --add` runs) make ANY `git remote` subcommand fail, including `set-url`.
await syncOriginFetchBranches(git!, branch, options?.additionalBranches);
// Set remote to auth URL when we have a token so fetch/pull/push do not prompt
if (githubToken && cloneUrl.startsWith('https://')) {
await git!.raw(['remote', 'set-url', 'origin', authUrl]);
debug('Set origin remote URL with token so fetch/pull/push do not prompt', {
tokenLength: githubToken.length,
});
} else if (!githubToken) {
debug('No GitHub token provided - fetch/push may prompt for credentials');
}
if (options?.preserveChanges) {
// Preserve existing changes - just make sure we're on the right branch
console.log(`Existing workdir found at ${workdir}, preserving local changes...`);
// Abort any stuck rebase/merge/cherry-pick from a previous failed run.
// Without this, a prior crash mid-rebase leaves the workdir in an
// unusable state and every subsequent run fails at the same point.
const rebaseMerge = join(workdir, '.git', 'rebase-merge');
const rebaseApply = join(workdir, '.git', 'rebase-apply');
const mergeHead = join(workdir, '.git', 'MERGE_HEAD');
const cherryPickHead = join(workdir, '.git', 'CHERRY_PICK_HEAD');
if (existsSync(rebaseMerge) || existsSync(rebaseApply) || existsSync(mergeHead) || existsSync(cherryPickHead)) {
console.log(' ⚠ Detected stuck rebase/merge from previous run, aborting...');
try { await git!.rebase(['--abort']); } catch { /* no rebase */ }
try { await git!.merge(['--abort']); } catch { /* no merge */ }
try { await git!.raw(['cherry-pick', '--abort']); } catch { /* no cherry-pick */ }
debug('Aborted stuck git operation in preserveChanges path');
}
const status = await git!.status();
const hasChanges = status.modified.length > 0 || status.created.length > 0 || status.staged.length > 0;
if (hasChanges) {
console.log(` Keeping ${status.modified.length + status.created.length} modified files`);
}
// Just ensure we're on the right branch, don't reset
try {
await git!.checkout(branch);
} catch {
// Already on branch or changes prevent checkout - that's fine
}
// WHY: Base-branch ref must exist for merge-base even when we preserve dirty workdir;
// the non-preserve path always fetched these — align behavior (fetch-only, no reset).
await fetchAdditionalBranches(git!, branch, options?.additionalBranches);
} else {
// Clean start - reset everything
console.log(`Existing workdir found at ${workdir}, cleaning up and fetching latest...`);
// Clean up any leftover merge/rebase state from previous runs (includes git clean -fd)
await cleanupGitState(git!);
// Ensure we're on a branch before reset (e.g. previous run left detached HEAD mid-rebase).
await git!.checkout(branch).catch(() => {});
await syncOriginFetchBranches(git!, branch, options?.additionalBranches);
// Run fetch via spawn with stdio inherit so user sees "Receiving objects" progress (not diffs).
console.log(' Fetching latest from origin (git output will appear below)...');
await new Promise<void>((resolve, reject) => {
const proc = spawn('git', ['fetch', 'origin', `+refs/heads/${branch}:refs/remotes/origin/${branch}`], {
cwd: workdir,
stdio: 'inherit',
});
proc.on('close', (code) => {
if (code === 0) resolve();
else reject(new Error(`git fetch exited ${code ?? 'unknown'}`));
});
proc.on('error', (err) => reject(err));
});
try {
await git!.checkout(branch);
} catch {
// Local branch may not exist (e.g. --single-branch clone for a different branch); create from remote.
await git!.raw(['checkout', '-b', branch, `origin/${branch}`]);
}
await git!.reset(['--hard', `origin/${branch}`]);
await fetchAdditionalBranches(git!, branch, options?.additionalBranches);
console.log(`Updated to latest ${branch}`);
}
}
if (!isExistingRepo) {
// Fresh clone. Bootstrap from another workdir for same repo when possible (git is distributed).
const referencePath = findReferenceWorkdir(workdir, cloneUrl);
const cloneArgs = ['clone', '--branch', branch, '--single-branch', ...getCloneDepthArg()];
if (referencePath) {
cloneArgs.push('--reference', referencePath);
debug('Using reference workdir for clone', { referencePath, workdir });
console.log(` Bootstrapping from existing clone (${referencePath})...`);
}
const cloneTimeoutMs = getCloneTimeoutMs();
const depthNote = process.env.PRR_CLONE_DEPTH?.trim() ? ` shallow depth=${process.env.PRR_CLONE_DEPTH.trim()};` : '';
console.log(`Cloning repository to ${workdir}...`);
console.log(
` (Git output and any prompts will appear below. Timeout: ${Math.round(cloneTimeoutMs / 1000)}s; PRR_CLONE_TIMEOUT_MS for slow connections.${depthNote} PRR_CLONE_DEPTH for shallow clone.)`,
);
await new Promise<void>((resolve, reject) => {
const proc = spawn('git', [...cloneArgs, authUrl, workdir], {
stdio: 'inherit',
});
let settled = false;
const settle = (fn: () => void) => {
if (settled) return;
settled = true;
clearTimeout(timeoutId);
clearInterval(progressTimer);
fn();
};
let timeoutId: ReturnType<typeof setTimeout>;
timeoutId = setTimeout(() => {
proc.kill('SIGKILL');
settle(() =>
reject(
new Error(
`Clone timed out after ${Math.round(cloneTimeoutMs / 1000)}s. Set PRR_CLONE_TIMEOUT_MS to increase (e.g. 600000 for 10min).`,
),
),
);
}, cloneTimeoutMs);
const progressIntervalMs = 30_000;
let progressElapsed = 0;
const progressTimer = setInterval(() => {
progressElapsed += progressIntervalMs / 1000;
console.log(` Still cloning... (${Math.floor(progressElapsed)}s)`);
}, progressIntervalMs);
proc.on('close', (code) => {
settle(() => {
if (code === 0) resolve();
else reject(new Error(`git clone exited ${code ?? 'unknown'}`));
});
});
proc.on('error', (err) => {
settle(() => reject(err));
});
});
git = simpleGit(workdir);
await fetchAdditionalBranches(git, branch, options?.additionalBranches);
console.log(`Cloned ${branch} successfully`);
}
if (git === undefined) {
throw new Error('cloneOrUpdate: internal error — git instance was not initialized');
}
const verifyRefs = options?.verifyAdditionalRemoteRefs !== false;
if (verifyRefs) {
await assertAdditionalBranchTrackingRefs(git, branch, options?.additionalBranches);
}
return { git, workdir };
}