320 lines
10 KiB
TypeScript
320 lines
10 KiB
TypeScript
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<string> => {
|
|
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<string[]> => {
|
|
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<Set<string>> => {
|
|
const commits = await runGitCommand(["rev-list", branch]);
|
|
return new Set(commits.split("\n").filter(Boolean));
|
|
};
|
|
|
|
const removeIgnoredBranches = (branchesWithDependencies: Record<string, any>) => {
|
|
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<Record<string, BranchWithDependencies>> => {
|
|
const commitHistories: Record<string, Set<string>> = {};
|
|
for (const branch of branches) {
|
|
commitHistories[branch] = await getCommitsForBranch(branch);
|
|
writeConsole(`Commits ${ branch }`, LogLevel.DEBUG);
|
|
writeConsole(commitHistories[branch], LogLevel.DEBUG);
|
|
}
|
|
|
|
let finalBranches: Record<string, BranchWithDependencies> = {};
|
|
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<string> = 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<string, BranchWithDependencies>): 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<RebaseAction & { success: boolean }> => {
|
|
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<void> => {
|
|
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.replace("origin/",""), Action[rebased.action], rebased.onBranch.replace("origin/","")] as string[]);
|
|
|
|
if(failedRebase.length === 0) {
|
|
writeConsole("All rebase where done successfully", LogLevel.INFO);
|
|
} else {
|
|
writeConsole("Some rebases failed.", LogLevel.WARNING);
|
|
writeConsole(styledTable([
|
|
["Branch", "Tried Action", "Onto branch"],
|
|
...failedRebase
|
|
]), LogLevel.WARNING);
|
|
}
|
|
} catch (error) {
|
|
writeConsole("Error during workflow execution", LogLevel.ERROR);
|
|
writeConsole(error, LogLevel.ERROR);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
// Execute the script
|
|
main();
|