390 lines · 16.2 KB · TypeScript
RawDownload
/** * 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 };}