255 lines
7.7 KiB
JavaScript
255 lines
7.7 KiB
JavaScript
import { execFileSync } from "node:child_process";
|
|
import { resolve } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const protectedUpdaterPaths = [
|
|
".github/workflows",
|
|
"scripts/prepare-upstream-sync.mjs",
|
|
"scripts/upstream-sync-plan.mjs",
|
|
];
|
|
|
|
function runGit(arguments_, { allowFailure = false, cwd = process.cwd() } = {}) {
|
|
try {
|
|
const stdout = execFileSync("git", arguments_, {
|
|
cwd,
|
|
encoding: "utf8",
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
return { status: 0, stderr: "", stdout };
|
|
} catch (error) {
|
|
const status = Number.isInteger(error.status) ? error.status : 1;
|
|
const stderr = String(error.stderr || "");
|
|
const stdout = String(error.stdout || "");
|
|
if (allowFailure) return { status, stderr, stdout };
|
|
|
|
const details = [stdout, stderr]
|
|
.map((value) => value.trim())
|
|
.filter(Boolean)
|
|
.join("\n");
|
|
throw new Error(`git ${arguments_.join(" ")} failed${details ? `:\n${details}` : ""}`);
|
|
}
|
|
}
|
|
|
|
function resolveCommit(cwd, revision) {
|
|
const result = runGit(["rev-parse", "--verify", `${revision}^{commit}`], { cwd });
|
|
const commit = result.stdout.trim();
|
|
if (!commit) {
|
|
throw new Error(`git rev-parse returned no commit for ${revision} in ${cwd}`);
|
|
}
|
|
return commit;
|
|
}
|
|
|
|
function restoreProtectedUpdater(cwd, baseCommit) {
|
|
for (const path of protectedUpdaterPaths) {
|
|
const pathExists = runGit(["cat-file", "-e", `${baseCommit}:${path}`], {
|
|
allowFailure: true,
|
|
cwd,
|
|
});
|
|
|
|
if (pathExists.status === 0) {
|
|
runGit(
|
|
["restore", `--source=${baseCommit}`, "--staged", "--worktree", "--", path],
|
|
{ cwd },
|
|
);
|
|
} else {
|
|
runGit(["rm", "-r", "-f", "--ignore-unmatch", "--", path], { cwd });
|
|
}
|
|
}
|
|
}
|
|
|
|
function requireNoUpdaterChanges(cwd, baseCommit) {
|
|
const result = runGit(
|
|
["diff", "--cached", "--quiet", baseCommit, "--", ...protectedUpdaterPaths],
|
|
{ allowFailure: true, cwd },
|
|
);
|
|
if (result.status === 1) {
|
|
throw new Error("The downstream updater layer must remain unchanged in deployment forks");
|
|
}
|
|
if (result.status !== 0) {
|
|
throw new Error(`Could not verify the protected updater layer (git diff exited ${result.status})`);
|
|
}
|
|
}
|
|
|
|
function requirePreparedChanges(cwd, baseCommit) {
|
|
const result = runGit(["diff", "--cached", "--quiet", baseCommit], {
|
|
allowFailure: true,
|
|
cwd,
|
|
});
|
|
if (result.status === 0) {
|
|
throw new Error("The upstream sync plan requested an update, but no managed files changed");
|
|
}
|
|
if (result.status !== 1) {
|
|
throw new Error(`Could not verify the prepared update (git diff exited ${result.status})`);
|
|
}
|
|
}
|
|
|
|
function prepareSnapshot(cwd, baseCommit, targetCommit) {
|
|
runGit(["read-tree", "--reset", "-u", targetCommit], { cwd });
|
|
restoreProtectedUpdater(cwd, baseCommit);
|
|
|
|
const managedTreeMatches = runGit(
|
|
[
|
|
"diff",
|
|
"--cached",
|
|
"--quiet",
|
|
targetCommit,
|
|
"--",
|
|
".",
|
|
":(exclude).github/workflows/**",
|
|
":(exclude)scripts/prepare-upstream-sync.mjs",
|
|
":(exclude)scripts/upstream-sync-plan.mjs",
|
|
],
|
|
{ allowFailure: true, cwd },
|
|
);
|
|
if (managedTreeMatches.status !== 0) {
|
|
throw new Error("Prepared deployment tree does not match the selected upstream target");
|
|
}
|
|
}
|
|
|
|
function resolveCustomizedMergeBase(cwd, baseCommit, targetCommit) {
|
|
const message = runGit(
|
|
[
|
|
"log",
|
|
"-1",
|
|
"--grep=^EdgeEver-Upstream-Commit: ",
|
|
"--format=%B",
|
|
baseCommit,
|
|
],
|
|
{ cwd },
|
|
).stdout;
|
|
const recordedRevision = message.match(/^EdgeEver-Upstream-Commit:\s*(\S+)\s*$/m)?.[1];
|
|
if (recordedRevision) {
|
|
const recordedCommit = runGit(
|
|
["rev-parse", "--verify", `${recordedRevision}^{commit}`],
|
|
{ allowFailure: true, cwd },
|
|
);
|
|
if (recordedCommit.status === 0 && recordedCommit.stdout.trim()) {
|
|
return recordedCommit.stdout.trim();
|
|
}
|
|
}
|
|
|
|
return runGit(["merge-base", baseCommit, targetCommit], { cwd }).stdout.trim();
|
|
}
|
|
|
|
function prepareCustomizedMerge(cwd, baseCommit, targetCommit) {
|
|
const mergeBase = resolveCustomizedMergeBase(cwd, baseCommit, targetCommit);
|
|
if (!mergeBase) throw new Error("Could not resolve the customized fork's upstream base");
|
|
|
|
const replaceRef = `refs/replace/${baseCommit}`;
|
|
if (runGit(["show-ref", "--quiet", "--verify", replaceRef], {
|
|
allowFailure: true,
|
|
cwd,
|
|
}).status === 0) {
|
|
throw new Error(`Refusing to overwrite existing Git replacement ${replaceRef}`);
|
|
}
|
|
|
|
const baseTree = runGit(["rev-parse", `${baseCommit}^{tree}`], { cwd }).stdout.trim();
|
|
const syntheticCommit = runGit(
|
|
[
|
|
"commit-tree",
|
|
baseTree,
|
|
"-p",
|
|
mergeBase,
|
|
"-m",
|
|
"temporary EdgeEver customized sync base",
|
|
],
|
|
{ cwd },
|
|
).stdout.trim();
|
|
runGit(["replace", baseCommit, syntheticCommit], { cwd });
|
|
|
|
let mergedTree = "";
|
|
try {
|
|
const merge = runGit(["merge", "--no-commit", "--no-ff", targetCommit], {
|
|
allowFailure: true,
|
|
cwd,
|
|
});
|
|
const mergeInProgress = runGit(["rev-parse", "--quiet", "--verify", "MERGE_HEAD"], {
|
|
allowFailure: true,
|
|
cwd,
|
|
}).status === 0;
|
|
|
|
if (merge.status !== 0 && !mergeInProgress) {
|
|
const details = [merge.stdout, merge.stderr]
|
|
.map((value) => value.trim())
|
|
.filter(Boolean)
|
|
.join("\n");
|
|
throw new Error(
|
|
`Could not start the customized upstream merge${details ? `:\n${details}` : ""}`,
|
|
);
|
|
}
|
|
|
|
restoreProtectedUpdater(cwd, baseCommit);
|
|
const unresolved = runGit(["diff", "--name-only", "--diff-filter=U"], { cwd })
|
|
.stdout.trim()
|
|
.split("\n")
|
|
.filter(Boolean);
|
|
|
|
if (unresolved.length > 0) {
|
|
throw new Error(`Customized fork conflicts with upstream in: ${unresolved.join(", ")}`);
|
|
}
|
|
if (!mergeInProgress) {
|
|
throw new Error("Customized merge did not create a merge result");
|
|
}
|
|
|
|
mergedTree = runGit(["write-tree"], { cwd }).stdout.trim();
|
|
} finally {
|
|
runGit(["merge", "--abort"], { allowFailure: true, cwd });
|
|
runGit(["replace", "-d", baseCommit], { allowFailure: true, cwd });
|
|
}
|
|
|
|
if (!mergedTree) throw new Error("Customized merge did not produce a tree");
|
|
runGit(["read-tree", "--reset", "-u", mergedTree], { cwd });
|
|
}
|
|
|
|
export function prepareUpstreamSync({
|
|
alignMode,
|
|
baseRevision = "HEAD",
|
|
cwd = process.cwd(),
|
|
targetRevision,
|
|
}) {
|
|
if (!targetRevision) throw new Error("targetRevision is required");
|
|
if (!new Set(["merge", "snapshot"]).has(alignMode)) {
|
|
throw new Error(`Unsupported upstream sync align mode: ${alignMode}`);
|
|
}
|
|
|
|
const trackedStatus = runGit(["status", "--porcelain=v1", "--untracked-files=no"], { cwd })
|
|
.stdout.trim();
|
|
if (trackedStatus) {
|
|
throw new Error(`Upstream sync requires a clean tracked worktree:\n${trackedStatus}`);
|
|
}
|
|
|
|
const baseCommit = resolveCommit(cwd, baseRevision);
|
|
const targetCommit = resolveCommit(cwd, targetRevision);
|
|
|
|
if (alignMode === "snapshot") {
|
|
prepareSnapshot(cwd, baseCommit, targetCommit);
|
|
} else {
|
|
prepareCustomizedMerge(cwd, baseCommit, targetCommit);
|
|
}
|
|
|
|
requireNoUpdaterChanges(cwd, baseCommit);
|
|
requirePreparedChanges(cwd, baseCommit);
|
|
|
|
return { alignMode, baseCommit, targetCommit };
|
|
}
|
|
|
|
function runCli() {
|
|
if (process.argv[2] !== "prepare") {
|
|
throw new Error("Usage: node scripts/prepare-upstream-sync.mjs prepare");
|
|
}
|
|
|
|
const result = prepareUpstreamSync({
|
|
alignMode: process.env.EDGE_SYNC_ALIGN_MODE,
|
|
baseRevision: process.env.EDGE_SYNC_BASE_COMMIT || "HEAD",
|
|
targetRevision: process.env.EDGE_SYNC_TARGET_COMMIT,
|
|
});
|
|
process.stdout.write(
|
|
`[ok] prepared ${result.alignMode} update ${result.baseCommit.slice(0, 12)} -> ${result.targetCommit.slice(0, 12)} without changing the downstream updater layer\n`,
|
|
);
|
|
}
|
|
|
|
const invokedPath = process.argv[1] ? resolve(process.argv[1]) : "";
|
|
if (invokedPath === fileURLToPath(import.meta.url)) {
|
|
runCli();
|
|
}
|