Files
kandev/apps/web/scripts/lib/git-base.mjs
T
João Salavisa f26befdc33 fix(ci): stop the i18n ratchet failing PRs on files they never touched (#2165)
Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 18:58:30 +02:00

127 lines
4.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Shared git plumbing for the i18n ratchet checks.
*
* Both `check-new-code-i18n.mjs` and `check-guard-allowlist.mjs` need the same
* fork point and the same fail-closed policy about it, so it lives here rather
* than being duplicated and drifting.
*/
import { execFileSync } from "node:child_process";
import path from "node:path";
export const WEB_DIR = path.resolve(import.meta.dirname, "..", "..");
export const REPO_ROOT = path.resolve(WEB_DIR, "..", "..");
/** The branch every PR is judged against. */
export const BASE_BRANCH = "origin/main";
export function git(args, { quiet = false, cwd = REPO_ROOT } = {}) {
return execFileSync("git", args, {
cwd,
encoding: "utf8",
maxBuffer: 64 * 1024 * 1024,
stdio: ["ignore", "pipe", quiet ? "ignore" : "inherit"],
});
}
/**
* Git reports paths with `/` on every platform; `path.relative` returns `\` on
* Windows. Comparing the two silently matches nothing, which would make the
* ratchet discard every finding and report "clean" — the worst failure mode for
* a guard, because it looks like success.
*/
export function toPosixPath(value) {
return value.split(path.sep).join("/");
}
function mergeBase(ref, cwd) {
return git(["merge-base", ref, "HEAD"], { quiet: true, cwd }).trim();
}
function isAncestor(maybeAncestor, descendant, cwd) {
try {
git(["merge-base", "--is-ancestor", maybeAncestor, descendant], { quiet: true, cwd });
return true;
} catch {
return false;
}
}
/**
* Raise a caller-supplied base to the point HEAD forked from the base branch.
*
* An explicit `--base` is routinely STALE. On a `pull_request` event CI checks
* out the MERGE REF (`refs/pull/N/merge`), whose first parent is the base branch
* tip at check time — but `github.event.pull_request.base.sha` is the base tip
* from when the PR event fired. The stale sha is therefore an *ancestor* of
* HEAD, so `merge-base` hands it straight back, and every commit the base branch
* landed in between reads as "new code in this PR". That fails the ratchet on
* files the change never touched, and it is not fixed by a three-dot diff:
* `base...HEAD` collapses to `base..HEAD` precisely because base is an ancestor.
* Rebasing a branch onto a newer base produces the same shape with no merge
* commit at all.
*
* The ratchet's contract is that it never asks you to migrate code you did not
* write, so the base must never sit older than the fork point.
*/
function forkPointFloor(base, cwd) {
// HEAD is contained in the base branch — a `push` build of that branch. There
// is no fork point (the merge base IS HEAD), and advancing to it would compare
// HEAD against itself and wave every commit through. Trust the caller's base.
if (isAncestor("HEAD", BASE_BRANCH, cwd)) return base;
let forkPoint;
try {
forkPoint = mergeBase(BASE_BRANCH, cwd);
} catch {
// No base branch in this checkout, so there is nothing better to offer.
return base;
}
if (forkPoint === base || !isAncestor(base, forkPoint, cwd)) return base;
console.log(
`ℹ base advanced to the ${BASE_BRANCH} fork point ${forkPoint.slice(0, 9)} — ` +
`the --base given was older, and everything ${BASE_BRANCH} landed in between\n` +
` is not this change's code.`,
);
return forkPoint;
}
/**
* The fork point to judge against, or `null` when the caller explicitly allows
* running without one.
*
* Fail-closed: if the caller passed `--base`, or we are in CI, an unresolvable
* base means a broken checkout (usually a shallow clone that never fetched the
* base), and skipping would turn that into a silent pass. Only a bare local run
* — a developer with no `origin/main`, e.g. offline or in a fresh init repo —
* is allowed to skip, because failing there would block unrelated commits for
* an environment reason.
*
* @returns {{ base: string } | { skip: string }}
*/
export function resolveBase(argv = process.argv, cwd = REPO_ROOT) {
const flag = argv.indexOf("--base");
const explicit = flag !== -1 && argv[flag + 1] ? argv[flag + 1] : null;
const requested = explicit ?? BASE_BRANCH;
let base;
try {
base = mergeBase(requested, cwd);
} catch {
if (explicit || process.env.CI) {
console.error(
`\n✖ cannot resolve a base to compare against (tried "${requested}").\n` +
` In CI this means the checkout is incomplete — a shallow clone that\n` +
` never fetched the base branch. Refusing to pass silently.\n` +
` Fetch the base ref, or pass --base <sha>.\n`,
);
process.exit(1);
}
return { skip: `no base ref (tried "${requested}"); pass --base <sha> to check explicitly` };
}
// Only an explicit base can be stale; the default IS the fork point already.
return { base: explicit ? forkPointFloor(base, cwd) : base };
}