import { spawn } from "child_process"; import { ChildProcess, SpawnOptions } from "node:child_process"; import * as core from '@actions/core'; import { LogLevel, styledTable, writeConsole } from "@skydust/toolkit"; // Ignored branches const IGNORED_BRANCHES: string[] = core.getInput('branchesToIgnore', { required: false }).split(","); const mainBranch: string = `origin/${ core.getInput('mainBranch', { required: false }) }`; const CI_TOKEN: string = core.getInput('ciToken', { required: false, trimWhitespace: true }); const { GITHUB_SERVER_URL } = process.env; enum Action { Rebase = 0, Reset = 1 } interface RebaseAction { branch: string, onBranch: string, action: Action } interface BranchWithDependencies { rebaseBranch?: string, differenceWithRebase?: number, equalBranches?: string[], ignore?: boolean } /** * Helper function to run a Git command and capture stdout and stderr. */ const runGitCommand = (args: string[], options?: SpawnOptions): Promise => { writeConsole(`Running git command: git ${ args.join(" ") }`); return new Promise((resolve, reject) => { const git: ChildProcess = spawn("git", args, { stdio: "pipe", ...options }); let stdout: string = ""; let stderr: string = ""; git.stdout.on("data", (data) => stdout += data.toString()); git.stderr.on("data", (data) => stderr += data.toString()); // Handle completion git.on("close", (code) => (code === 0) ? resolve(stdout) : reject(new Error(`Git command failed with code ${code}: ${stderr}`))); }); } /** * Fetch all remote branches. */ const fetchBranches = async (): Promise => { writeConsole("Fetching all branches..."); await runGitCommand(["fetch", "--all"]); const branches: string = await runGitCommand(["branch", "-r"]); return branches .split("\n") .map((branch) => branch.trim()) .filter((branch) => branch !== "" && !IGNORED_BRANCHES .some(ignored => new RegExp(`^${ ignored }$`) .test(branch.replace("origin/", "")))); } // Get the full commit history for a branch const getCommitsForBranch = async (branch: string): Promise> => { const commits = await runGitCommand(["rev-list", branch]); return new Set(commits.split("\n").filter(Boolean)); }; const removeIgnoredBranches = (branchesWithDependencies: Record) => { let withoutIgnoredBranches = {} for (const [branch, value] of Object.entries(branchesWithDependencies)) { if(value.ignore) continue; withoutIgnoredBranches[branch] = value; } return withoutIgnoredBranches; } // Build the dependency graph const buildRebaseDependencyGraph = async (branches: string[]): Promise> => { const commitHistories: Record> = {}; for (const branch of branches) { commitHistories[branch] = await getCommitsForBranch(branch); writeConsole(`Commits ${ branch }`, LogLevel.DEBUG); writeConsole(commitHistories[branch], LogLevel.DEBUG); } let finalBranches: Record = {}; for (const branchA of branches) { for (const branchB of branches) { if(branchA !== branchB) continue; const isSuperset: boolean = commitHistories[branchA].isSupersetOf(commitHistories[branchB]); if (!isSuperset) continue; const difference: Set = commitHistories[branchA].difference(commitHistories[branchB]); if(branchB === mainBranch) { // SUPERSET OF MAIN BRANCH, MEANING ALREADY REBASED finalBranches[branchA] = { ignore: true } } if (difference.size === 0) { const prevBranches: string[] = finalBranches[branchA]?.equalBranches ?? []; finalBranches[branchA] = { rebaseBranch: mainBranch, ...finalBranches[branchA], equalBranches: [...prevBranches, branchB] }; } else if (branchA !== mainBranch && (!finalBranches[branchA] || finalBranches[branchA].differenceWithRebase > difference.size)) { finalBranches[branchA] = { ...finalBranches[branchA], rebaseBranch: branchB, differenceWithRebase: difference.size }; } } } // Set rebase for branches with no dependencies for (const branch of branches) { if(branch !== mainBranch) continue; finalBranches[branch] = finalBranches[branch] ?? { rebaseBranch: mainBranch, differenceWithRebase: 0 } } return removeIgnoredBranches(finalBranches); }; const rebaseOrder = (branchesWithDependencies: Record): RebaseAction[] => { writeConsole("Order everything", LogLevel.DEBUG); // First choose the right actions let orderedActions: RebaseAction[] = [] for (const branch of Object.keys(branchesWithDependencies)) { const alreadyRebasedEqualBranch = orderedActions.find(action => branchesWithDependencies[branch].equalBranches?.some(otherBranch => otherBranch === action.branch)) if(alreadyRebasedEqualBranch) { orderedActions.push({ branch, onBranch: alreadyRebasedEqualBranch.branch, action: Action.Reset }) } else { orderedActions.push({ branch, onBranch: branchesWithDependencies[branch].rebaseBranch, action: Action.Rebase }); } } // Then order by differenceWithRebase and then add resets orderedActions = orderedActions.sort((a,b) => { const diffA = branchesWithDependencies[a.branch].differenceWithRebase; const diffB = branchesWithDependencies[b.branch].differenceWithRebase; return diffA - diffB; }) orderedActions = orderedActions.sort((a,b) => a.action - b.action) return orderedActions; } const rebaseBranch = async (rebaseAction: RebaseAction): Promise => { let { branch, onBranch, action } = rebaseAction; writeConsole(`${ action === Action.Rebase ? "Rebasing" : "Resetting" } ${ branch } on ${ onBranch }`); let doneWithSuccess = true; await runGitCommand([ "checkout", branch.replace("origin/", "") ]); if(action === Action.Rebase) { await runGitCommand([ "rebase", onBranch ]).catch(async error => { writeConsole(`Failed to rebase ${ branch } on ${ onBranch }`, LogLevel.WARNING); writeConsole(error?.message, LogLevel.WARNING) // Using message to not show the stack await runGitCommand([ "rebase", "--abort" ]) doneWithSuccess = false; }); } else if(action === Action.Reset) { await runGitCommand([ "reset", "--hard", onBranch ]); } await runGitCommand([ "push", "--force-with-lease" ]); return { ...rebaseAction, success: doneWithSuccess }; } const setupCIToken = async () => { writeConsole("Overriding git repository auth with CI_TOKEN") const remoteUrl = await runGitCommand([ "remote", "get-url", "origin" ]); const httpLessUrl = remoteUrl.trim().replace(/https?:\/\//, ""); const newUrl = `https://MilaBot:${ CI_TOKEN }@${ httpLessUrl }.git`; await runGitCommand([ "remote", "set-url", "origin", newUrl ]); await runGitCommand([ "config", "--local", "--unset", `http.https://${ GITHUB_SERVER_URL.replace(/https?:\/\//, "") }/.extraheader` ]); } const setupAutoRebaseGit = async () => { if (CI_TOKEN) { await setupCIToken(); } else { writeConsole("Couldn't find a CI_TOKEN. Using the gitea ghost.", LogLevel.WARNING); } await runGitCommand([ "config", "user.email", "auto-rebase@skydust.fr" ]); await runGitCommand([ "config", "user.name", "Auto Rebase" ]); } /** * Main function to execute the workflow. */ const main = async (): Promise => { try { writeConsole("Setup auto rebase"); await setupAutoRebaseGit(); // Step 1: Fetch branches const branches: string[] = (await fetchBranches()); branches.push(mainBranch) writeConsole("Branches"); writeConsole(branches); // Step 2: Detect dependencies writeConsole("Determining dependencies..."); const dependencies = await buildRebaseDependencyGraph(branches); writeConsole(dependencies, LogLevel.DEBUG); // Step 3: Determine rebase order const order = rebaseOrder(dependencies); if (order.length === 0) { writeConsole("Nothing to rebase"); } else { writeConsole("Rebase order"); writeConsole(order); } // Step 4: Rebase branches const rebasedBranches: (RebaseAction & { success: boolean })[] = []; for (const rebaseAction of order) { rebasedBranches.push(await rebaseBranch(rebaseAction)); } const failedRebase = rebasedBranches.filter(rebased => !rebased.success) .map(rebased => [rebased.branch, rebased.onBranch, rebased.action] as string[]); if(failedRebase.length === 0) { writeConsole("All rebase where done successfully", LogLevel.INFO); } else { writeConsole("Some rebase failed", LogLevel.WARNING); styledTable([ ["Branch", "Action", "Onto branch"], ...failedRebase ]); } } catch (error) { writeConsole("Error during workflow execution", LogLevel.ERROR); writeConsole(error, LogLevel.ERROR); process.exit(1); } } // Execute the script main();