/** * 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. Load more
/** * 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) {