649 lines · 29.8 KB · TypeScript
RawDownload
/** * split-exec runner: read plan, clone repo, execute each split (cherry-pick, push, create PR). * WHY iterative: Process one split at a time so the user can fix conflicts or stop between splits. * When a rewrite plan is present, runs only its ordered ops (cherry-pick / commit-from-sha) on rebuild branches; when absent, does bare minimum (one commit per split from Files). WHY staleness check: Refuse to run if source_tip_sha !== current source tip so we never apply an outdated plan. */import chalk from 'chalk';import { execFileSync } from 'child_process';import { existsSync } from 'fs';import ora, { type Ora } from 'ora';import { join } from 'path';import type { Config } from '../../shared/config.js';import type { SplitExecOptions } from './cli.js';import { parsePlanFile, type ParsedPlan, type ParsedSplit } from './parse-plan.js';import { parseRewritePlanFile, resolveRewritePlanPath, type RewritePlan, type RewritePlanOp } from './rewrite-plan.js';import { GitHubAPI } from '../prr/github/api.js';import { cloneOrUpdate } from '../../shared/git/git-clone-index.js';import { pushWithRetry } from '../../shared/git/git-push.js';import type { SimpleGit } from 'simple-git';import { debug, formatNumber } from '../../shared/logger.js'; /** True when the error is GitHub auth failure (invalid/expired token or password auth). WHY: so we can fail fast and not run remaining splits with the same error. */function isAuthError(error: Error): boolean {  const msg = error.message.toLowerCase();  return (    /authentication failed/i.test(msg) ||    /invalid username or token/i.test(msg) ||    /password authentication is not supported/i.test(msg) ||    /bad credentials/i.test(msg)  );} /** Fail fast if target branch does not exist on remote (pill-output #1). */function ensureTargetBranchExistsOnRemote(  cloneUrl: string,  targetBranch: string,  githubToken: string | undefined): void {  const authUrl = githubToken && cloneUrl.startsWith('https://')    ? cloneUrl.replace('https://', `https://${githubToken}@`)    : cloneUrl;  try {    const out = execFileSync('git', ['ls-remote', '--heads', authUrl, `refs/heads/${targetBranch}`], {      encoding: 'utf8',      stdio: ['pipe', 'pipe', 'pipe'],    });    if (!out.trim() || !out.includes(`refs/heads/${targetBranch}`)) {      throw new Error(`Target branch ${targetBranch} does not exist on remote. Create it first or update the split plan.`);    }  } catch (err) {    if (err instanceof Error && err.message.includes('does not exist on remote')) throw err;    const msg = err instanceof Error ? err.message : String(err);    if (msg.includes('could not read') || msg.includes('not found') || msg.includes('does not exist')) {      throw new Error(`Target branch ${targetBranch} does not exist on remote. Create it first or update the split plan.`);    }    throw new Error(`Could not verify target branch ${targetBranch} on remote: ${msg}`);  }} export async function runSplitExec(  planPath: string,  config: Config,  options: SplitExecOptions): Promise<void> {  if (!existsSync(planPath)) {    throw new Error(`Plan file not found: ${planPath}. Usage: split-exec <plan-file> (e.g. .split-plan.md)`);  }  const spinner = ora();  spinner.start('Parsing plan...');  const plan: ParsedPlan = parsePlanFile(planPath);  spinner.succeed(`Plan: ${plan.splits.length} splits (${plan.sourceBranch} → ${plan.targetBranch})`);   // WHY optional rewrite plan: When absent we do bare minimum (one commit per split from Files); when present we run ordered ops on rebuild branches.  let rewritePlan: RewritePlan | null = null;  const rewritePlanPath = resolveRewritePlanPath(planPath, options.rewritePlan);  if (rewritePlanPath) {    try {      rewritePlan = parseRewritePlanFile(rewritePlanPath);      debug('split-exec: loaded rewrite plan', { path: rewritePlanPath, splits: rewritePlan.splits.length });    } catch (e) {      throw new Error(e instanceof Error ? e.message : String(e));    }  }   const cloneUrl = `https://github.com/${plan.owner}/${plan.repo}.git`;  const workdir = options.workdir ?? join(process.cwd(), '.split-exec-workdir');  const github = new GitHubAPI(config.githubToken);   if (!options.dryRun && config.githubToken) {    spinner.start('Checking GitHub token...');    try {      await github.getDefaultBranch(plan.owner, plan.repo);    } catch (err) {      const msg = err instanceof Error ? err.message : String(err);      const status = err && typeof err === 'object' && 'status' in err ? (err as { status: number }).status : undefined;      if (status === 401 || /bad credentials|invalid.*token|authentication failed/i.test(msg)) {        throw new Error(          `GITHUB_TOKEN rejected by GitHub (${status ?? 'auth error'}): ${msg}. ` +            'Create a token at https://github.com/settings/tokens with "repo" scope and set GITHUB_TOKEN in .env. ' +            'Ensure the token is not expired and the account has access to this repo.'        );      }      if (status === 403 || /forbidden|resource not accessible/i.test(msg)) {        throw new Error(          `GITHUB_TOKEN lacks permission for ${plan.owner}/${plan.repo}: ${msg}. ` +            'Token needs "repo" scope and the account must have access to this repository.'        );      }      if (status === 404 || /not found/i.test(msg)) {        throw new Error(          `Repository ${plan.owner}/${plan.repo} not found or not accessible: ${msg}. ` +            'Check the repo exists and your token has access.'        );      }      throw err;    }    spinner.succeed('Token valid');  }   spinner.start('Checking target branch on remote...');  ensureTargetBranchExistsOnRemote(cloneUrl, plan.targetBranch, config.githubToken);  spinner.succeed(`Target branch ${plan.targetBranch} exists`);   // WHY only target_branch (when ≠ source): **New PR:** names are local output branches — not on origin until push.  // Prefetching every split branch caused "not found" noise and invalid refspecs when names contained `:`.  const cloneAdditionalBranches =    plan.targetBranch !== plan.sourceBranch ? [plan.targetBranch] : undefined;  // No spinner during clone — git clone/fetch output is shown directly.  const { git } = await cloneOrUpdate(cloneUrl, plan.sourceBranch, workdir, config.githubToken, {    additionalBranches: cloneAdditionalBranches,  });  spinner.succeed(`Workdir: ${workdir}`);   // Fail fast if target branch is missing in workdir (e.g. fetch failed silently in edge cases).  if (!options.dryRun) {    await ensureTargetRefOrFetch(git, plan.targetBranch, workdir);  }   // Staleness check: when rewrite plan is loaded, source branch tip must match plan's source_tip_sha.  // WHY fail: Applying a plan built from a different source tip would replay wrong commits or apply ops that don't match the current tree; failing is safer than silent wrong branches.  if (!options.dryRun && rewritePlan != null) {    const currentTip = (await git.raw(['rev-parse', 'HEAD'])).trim();    let planTipFull: string;    try {      planTipFull = (await git.raw(['rev-parse', rewritePlan.source_tip_sha])).trim();    } catch {      throw new Error(        `Rewrite plan source_tip_sha ${rewritePlan.source_tip_sha} is not a valid commit in the repo. Re-run the rewrite-plan generator.`      );    }    if (currentTip !== planTipFull) {      throw new Error(        `Rewrite plan was built from ${rewritePlan.source_tip_sha} (${planTipFull}); source branch is now at ${currentTip.slice(0, 7)}. Re-run the rewrite-plan generator.`      );    }  }   let executedCount = 0;  let prsCreated = 0;  let prsReused = 0;  let skippedNothingToPush = 0;  const prUrls: string[] = [];  const failedSplits: { split: ParsedSplit; index: number; error: Error }[] = [];  const executedRebuildBranches: { newBranch: string; rebuildBranch: string }[] = [];   for (let i = 0; i < plan.splits.length; i++) {    const split = plan.splits[i];    const n = i + 1;    console.log(chalk.cyan(`\n[${n}/${plan.splits.length}] ${split.title}`));     const rewriteSplit = rewritePlan?.splits.find((rs) => rs.branchName === split.newBranch);    const useRewritePlan = rewritePlan != null && rewriteSplit != null && rewriteSplit.ops.length > 0;    // WHY skip when no matching rewrite entry: When rewrite plan is loaded we only run rewrite ops and push to rebuild branches; falling back to file-based for one split would push to original branch name and mix strategies.    if (rewritePlan != null && rewriteSplit == null && split.newBranch != null) {      console.log(chalk.yellow(`  Rewrite plan has no entry for branch "${split.newBranch}" — skipping (fix plan or re-run split-rewrite-plan)`));      continue;    }    if (rewritePlan != null && rewriteSplit != null && rewriteSplit.ops.length === 0) {      console.log(chalk.yellow('  Rewrite plan has no ops for this split — skipping'));      continue;    }    if (!useRewritePlan && split.files.length === 0 && split.commits.length === 0) {      console.log(chalk.yellow('  No **Files:** nor **Commits:** listed — skipping'));      if (split.rawCommitLines.length > 0) {        console.log(chalk.gray('  Parser saw commit section:'));        split.rawCommitLines.forEach((l) => console.log(chalk.gray('    ' + l)));      } else {        console.log(chalk.gray('  Add **Files:** (recommended) or **Commits:** for this split.'));      }      continue;    }    if (options.dryRun) {      if (useRewritePlan) {        console.log(chalk.gray(`  Dry run: would execute ${formatNumber(rewriteSplit!.ops.length)} rewrite-plan op(s) on ${split.newBranch}${options.rebuildSuffix}`));      } else if (split.files.length > 0) {        console.log(chalk.gray(`  Dry run: would apply ${formatNumber(split.files.length)} file(s) from source (new commit)`));      } else {        console.log(chalk.gray(`  Dry run: would cherry-pick ${formatNumber(split.commits.length)} commits`));      }      if (split.routeToPrNumber != null) console.log(chalk.gray(`  Route to PR #${split.routeToPrNumber}`));      if (split.newBranch != null) console.log(chalk.gray(`  New PR branch: ${split.newBranch}${useRewritePlan ? options.rebuildSuffix : ''}`));      continue;    }    try {      const outcome = await executeSplit(        git,        plan,        split,        github,        config.githubToken,        workdir,        spinner,        options.forcePush,        useRewritePlan ? rewriteSplit!.ops : undefined,        useRewritePlan ? options.rebuildSuffix : undefined      );      if (useRewritePlan && split.newBranch != null) {        executedRebuildBranches.push({ newBranch: split.newBranch, rebuildBranch: split.newBranch + options.rebuildSuffix });      }      executedCount++;      if (outcome?.prCreated) prsCreated++;      if (outcome?.prReused) prsReused++;      if (outcome?.nothingToPush) skippedNothingToPush++;      if (outcome?.nothingToPush) {        console.log(chalk.blue(`  ℹ Nothing to push for split ${n} — remote already contains these commits (likely from a previous run).`));      }      if (outcome) {        if (outcome.nothingToPush) {          const prPart = outcome.prNumber != null ? ` — PR #${outcome.prNumber} (open)` : '';          console.log(chalk.gray(`  [${n}] Already up-to-date${prPart}`));        } else {          const prPart = outcome.prNumber != null            ? ` — PR #${outcome.prNumber} (${outcome.prCreated ? 'created' : 'updated'})`            : '';          console.log(chalk.gray(`  [${n}] Pushed${prPart}`));        }        if (outcome.prUrl) {          console.log(chalk.cyan(`      ${outcome.prUrl}`));          prUrls.push(outcome.prUrl);        }      }    } catch (err) {      const error = err instanceof Error ? err : new Error(String(err));      spinner.fail(`Failed: ${error.message}`);      if (isAuthError(error)) {        throw new Error(          'GitHub authentication failed. Set GITHUB_TOKEN in .env (create a token at https://github.com/settings/tokens with "repo" scope). ' +            'GitHub does not support password auth for Git operations.\n' + error.message        );      }      failedSplits.push({ split, index: n, error });      // Return to source branch so next split can create its branch from origin/target.      await git.checkout(plan.sourceBranch).catch(() => {});    }  }   // Promote: force-push each rebuild branch over the original branch. WHY opt-in: User verifies rebuild PRs first; only then overwrite the original branch so the new history becomes canonical.  if (!options.dryRun && options.promote && rewritePlan != null && executedRebuildBranches.length > 0) {    console.log(chalk.cyan('\nPromoting rebuild branches to originals...'));    for (const { newBranch, rebuildBranch } of executedRebuildBranches) {      spinner.start(`Force-pushing ${rebuildBranch} → ${newBranch}...`);      try {        await git.raw(['push', 'origin', `${rebuildBranch}:${newBranch}`, '--force']);        spinner.succeed(`Promoted ${rebuildBranch} → ${newBranch}`);      } catch (err) {        spinner.fail(`Promote failed: ${rebuildBranch} → ${newBranch}`);        throw new Error(          `Failed to force-push ${rebuildBranch} to ${newBranch}. ${err instanceof Error ? err.message : String(err)}`        );      }    }  }   if (plan.splits.length > 0 && executedCount === 0 && !options.dryRun && failedSplits.length === 0) {    throw new Error('No splits executed (all had no commits listed). Fix the plan file or parser.');  }  if (failedSplits.length > 0) {    console.log(chalk.red(`\n${formatNumber(failedSplits.length)} split(s) failed:`));    const sameMessage = failedSplits.every(({ error }) => error.message === failedSplits[0].error.message);    if (sameMessage && failedSplits.length > 1) {      console.log(chalk.red(`  ${failedSplits[0].error.message}`));      failedSplits.forEach(({ split, index }) => {        console.log(chalk.red(`  [${index}] ${split.title}`));      });    } else {      failedSplits.forEach(({ split, index, error }) => {        console.log(chalk.red(`  [${index}] ${split.title}: ${error.message}`));      });    }    const allPushFailures = failedSplits.every(({ error }) => /Push to .+ failed/i.test(error.message));    const anyAuthFailure = failedSplits.some(({ error }) => isAuthError(error));    if (allPushFailures && failedSplits.length > 0 && !anyAuthFailure) {      console.log(chalk.yellow('\n  Re-run with --force-push to overwrite remote branches, or resolve conflicts in the workdir and push manually.'));    }    throw new Error(      `${formatNumber(failedSplits.length)} of ${plan.splits.length} split(s) failed. Fix errors above and re-run, or edit the plan.`    );  }  console.log(chalk.green('\nDone.'));  const pushed = executedCount - skippedNothingToPush;  const summaryParts: string[] = [];  if (executedCount > 0) {    if (pushed > 0) summaryParts.push(`${formatNumber(pushed)} pushed`);    if (prsCreated > 0) summaryParts.push(`${formatNumber(prsCreated)} PR(s) created`);    if (prsReused > 0) summaryParts.push(`${formatNumber(prsReused)} existing PR(s) updated`);    if (skippedNothingToPush > 0) summaryParts.push(`${formatNumber(skippedNothingToPush)} already up-to-date`);    console.log(chalk.gray(`  ${formatNumber(executedCount)}/${plan.splits.length} splits — ${summaryParts.join(', ')}`));  }  if (skippedNothingToPush > 0 && skippedNothingToPush === executedCount) {    console.log(chalk.yellow(      '  All splits were already up-to-date (remote likely had these commits from a previous run). If you expected new changes, check that the source branch has commits not yet on the split branches.'    ));    if (prUrls.length > 0) {      console.log(chalk.cyan(`  Open PRs: ${prUrls.join('  ')}`));    }  } else if (prUrls.length > 0) {    console.log(chalk.cyan(`  PRs: ${prUrls.join('  ')}`));  }   // Machine-readable exit: CI can distinguish "all up-to-date" from "pushed new changes".  if (!options.dryRun && plan.splits.length > 0 && executedCount > 0) {    if (skippedNothingToPush === executedCount) {      process.exitCode = 2; // All splits already up-to-date; nothing pushed.    }    // Otherwise exitCode stays 0 (some or all splits pushed).  }} /** Ensure origin/<targetBranch> exists; if not, add refspec and fetch. Throw with workdir hint if still missing. */async function ensureTargetRefOrFetch(  git: SimpleGit,  targetBranch: string,  workdir: string): Promise<void> {  const ref = `origin/${targetBranch}`;  try {    await git.raw(['rev-parse', '--verify', ref]);    return;  } catch {    try {      await git.raw(['remote', 'set-branches', '--add', 'origin', targetBranch]);      await git.fetch('origin', targetBranch);      await git.raw(['rev-parse', '--verify', ref]);      return;    } catch {      throw new Error(        `Target branch ${targetBranch} does not exist on remote. Create it first or update the split plan. Workdir: ${workdir} — to fetch manually: cd ${workdir} && git fetch origin ${targetBranch}`      );    }  }} /** * Return true if origin/<branch> exists and we have no commits to push (remote already has our tip). * Fetches the branch first so we compare against latest. Used to skip push when re-running and remote * already has the commits, avoiding reject + rebase + second push. */async function isBranchAlreadyUpToDate(git: SimpleGit, branch: string): Promise<boolean> {  const ref = `origin/${branch}`;  try {    await git.raw(['remote', 'set-branches', '--add', 'origin', branch]);    await git.fetch('origin', branch);  } catch {    // Branch may not exist on remote yet (first push); proceed to push.    return false;  }  try {    await git.raw(['rev-parse', '--verify', ref]);  } catch {    return false; // No ref yet  }  try {    const out = await git.raw(['rev-list', '--count', `${ref}..HEAD`]);    const n = parseInt(out.trim(), 10);    return Number.isNaN(n) ? false : n === 0;  } catch {    return false;  }} /** Outcome of a completed split (undefined when split was skipped without attempting push). */type SplitOutcome = {  prCreated: boolean;  nothingToPush: boolean;  prReused?: boolean;  prUrl?: string;  prNumber?: number;  nothingToPushAfterRebase?: boolean;} | undefined; /** Verify each commit SHA exists in the repo; throw with clear message so we fail fast before cherry-pick. */async function verifyCommitShasInRepo(git: SimpleGit, split: ParsedSplit): Promise<void> {  for (const sha of split.commits) {    try {      const type = (await git.raw(['cat-file', '-t', sha])).trim();      if (type !== 'commit') {        throw new Error(`git cat-file returned "${type}", expected "commit"`);      }    } catch (err) {      const msg = err instanceof Error ? err.message : String(err);      throw new Error(`Commit ${sha} referenced in split ${split.index} ("${split.title}") not found in repo. ${msg}`);    }  }} /** Execute one split: checkout target branch, apply rewrite-plan ops or files/commits, push, create PR if new. */async function executeSplit(  git: SimpleGit,  plan: ParsedPlan,  split: ParsedSplit,  github: GitHubAPI,  githubToken: string,  workdir: string,  spinner: Ora,  forcePush: boolean,  rewriteOps?: RewritePlanOp[],  rebuildSuffix?: string): Promise<SplitOutcome> {  let branchToPush: string;   if (split.routeToPrNumber != null) {    // Route-to modifies an existing PR's branch (cherry-pick + force-push).    // Disabled: too risky — if something goes wrong the existing PR is corrupted    // and requires manual recovery. Use "New PR" splits instead and close the old PR.    throw new Error(      `Route-to PR #${split.routeToPrNumber} is disabled (modifying existing PR branches is too risky). ` +      `Change the plan to use **New PR:** instead, then close the old PR after the new one is merged.`    );  } else if (split.newBranch != null) {    branchToPush = split.newBranch + (rebuildSuffix ?? ''); // WHY rebuild suffix when using rewrite plan: Push to a new branch first so original is only overwritten when user runs --promote.    await ensureTargetRefOrFetch(git, plan.targetBranch, workdir);    spinner.start(`Creating branch ${branchToPush} from ${plan.targetBranch}...`);    try {      // Delete stale local branch from a previous run so -b doesn't fail with "already exists".      await git.raw(['branch', '-D', branchToPush]).catch(() => {});      await git.checkout(['-b', branchToPush, `origin/${plan.targetBranch}`]);    } catch (err) {      const msg = err instanceof Error ? err.message : String(err);      if (msg.includes('is not a commit') || msg.includes('not found')) {        throw new Error(          `Could not create branch ${branchToPush} from origin/${plan.targetBranch}. Ensure the target branch exists on the remote. Workdir: ${workdir} — try: cd ${workdir} && git fetch origin ${plan.targetBranch}`        );      }      throw err;    }    spinner.succeed(`Branch ${branchToPush} created`);  } else {    console.log(chalk.yellow('  Split has neither Route to nor New PR — skipping'));    return undefined;  }   if (rewriteOps != null && rewriteOps.length > 0) {    // Rewrite plan path: execute ops in order (cherry-pick or commit-from-sha). WHY no fallback: When plan is present we only run these ops so each split gets the phased history; do not mix with file-based.    for (let c = 0; c < rewriteOps.length; c++) {      const op = rewriteOps[c];      if (op.type === 'cherry-pick') {        spinner.start(`Cherry-picking ${op.sha.slice(0, 7)} (${c + 1}/${rewriteOps.length})...`);        try {          await git.raw(['cherry-pick', op.sha]);          spinner.succeed(`Cherry-picked ${op.sha.slice(0, 7)}`);        } catch (err) {          spinner.fail(`Cherry-pick failed for ${op.sha}`);          await git.raw(['cherry-pick', '--abort']).catch(() => {}); // WHY: Leave index clean so next run or manual recovery doesn't see a conflicted state.          throw new Error(            `Conflict or error cherry-picking ${op.sha}. Resolve in ${workdir} and run again, or edit the rewrite plan.`          );        }      } else {        // commit-from-sha        const msg = op.message ?? `From ${op.sha.slice(0, 7)}: ${(await git.raw(['log', '-1', '--format=%s', op.sha])).trim()}`;        spinner.start(`Applying ${op.sha.slice(0, 7)} (${op.paths.length} file(s)) (${c + 1}/${rewriteOps.length})...`);        try {          await git.raw(['checkout', op.sha, '--', ...op.paths]);          await git.add(op.paths);          await git.commit(msg);          spinner.succeed(`Committed from ${op.sha.slice(0, 7)}`);        } catch (err) {          spinner.fail(`Commit-from-sha failed for ${op.sha}`);          throw new Error(            `Could not apply ${op.sha} (paths: ${op.paths.join(', ')}). ${err instanceof Error ? err.message : String(err)}. Resolve in ${workdir}.`          );        }      }    }  } else if (split.files.length > 0) {    // File-based: copy only these files from source branch and make one new commit.    // WHY: Cherry-pick is all-or-nothing per commit; a commit that touches both workflow and ticker    // would pollute a "workflow-only" PR. Applying by file gives one clean commit per split.    spinner.start(`Applying ${formatNumber(split.files.length)} file(s) from ${plan.sourceBranch}...`);    try {      await git.raw(['checkout', plan.sourceBranch, '--', ...split.files]);      const status = await git.status();      const staged = status.staged.length;      if (staged === 0) {        spinner.warn('No changes (files match target); nothing to commit');        return { prCreated: false, nothingToPush: false };      }      const commitTitle = split.prTitle ?? split.title;      await git.commit(commitTitle);      spinner.succeed(`Committed ${formatNumber(staged)} file(s): ${commitTitle}`);    } catch (err) {

Lines 1–500 of 649.