import { simpleGit, SimpleGit } from "simple-git"; import { acquireRepo, releaseRepo } from "./git-storage"; export type RewriteOperation = | { type: "pick"; hash: string } | { type: "reword"; hash: string; newMessage: string } | { type: "squash"; hash: string } // Squash this into previous | { type: "drop"; hash: string }; export async function rewriteBranchHistory( repoOwner: string, repoName: string, baseBranch: string, headBranch: string, operations: RewriteOperation[] ): Promise { const repoPath = await acquireRepo(repoOwner, repoName); const git = simpleGit(repoPath); try { await git.fetch(); // 1. Reset to base // Create a temp branch to do the work const tempBranch = `rewrite-${Date.now()}`; await git.checkout(baseBranch); await git.pull(); await git.checkoutLocalBranch(tempBranch); // 2. Perform operations // We iterate through operations. // For squash, we need to handle "squash into previous". // The operations list should be in the Desired Order. // Example: [pick A, squash B, pick C] -> Result: A+B, C. for (let i = 0; i < operations.length; i++) { const op = operations[i]; if (op.type === "drop") continue; try { if (op.type === "pick") { // git cherry-pick await git.raw(["cherry-pick", op.hash]); } else if (op.type === "reword") { await git.raw(["cherry-pick", op.hash]); await git.commit(op.newMessage, { "--amend": null }); } else if (op.type === "squash") { // Squash: cherry-pick -n, then commit --amend (to previous) // If first commit is squash, it fails (cannot squash into nothing). // We assume valid input from UI. const previousMessage = await readCommitMessage(git, "HEAD"); const squashMessage = await readCommitMessage(git, op.hash); // 1. Apply changes without commit // cherry-pick -n await git.raw(["cherry-pick", "-n", op.hash]); // 2. Amend previous commit const combinedMessage = combineCommitMessages(previousMessage, squashMessage); await git.commit(combinedMessage, { "--amend": null }); } } catch (e: any) { if (e.message.includes("conflict")) { await git.raw(["cherry-pick", "--abort"]).catch(() => { }); throw new Error(`Conflict while processing commit ${op.hash}. Rewriting aborted.`); } throw e; } } // 3. Force push to head branch // Push tempBranch to origin/headBranch await git.push("origin", `${tempBranch}:${headBranch}`, { "--force": null }); // Clean up await git.checkout(baseBranch); await git.deleteLocalBranch(tempBranch, true); } finally { await releaseRepo(repoOwner, repoName, true); // True because we pushed } } export async function getRewriteOperationFiles( repoOwner: string, repoName: string, operations: RewriteOperation[] ): Promise { const repoPath = await acquireRepo(repoOwner, repoName); const git = simpleGit(repoPath); try { const files = new Set(); for (const op of operations) { const output = await git.raw(["show", "--pretty=format:", "--name-only", op.hash]); for (const line of output.split("\n")) { const path = line.trim(); if (path.length > 0) files.add(path); } } return [...files.values()]; } finally { await releaseRepo(repoOwner, repoName, false); } } async function readCommitMessage(git: SimpleGit, rev: string): Promise { return (await git.raw(["show", "-s", "--format=%B", rev])).trim(); } function combineCommitMessages(previousMessage: string, squashMessage: string): string { if (!previousMessage) return squashMessage || "squashed commit"; if (!squashMessage || previousMessage === squashMessage) return previousMessage; return `${previousMessage}\n\n${squashMessage}`.trim(); }