diff --git a/docs/operations/development.md b/docs/operations/development.md index 0501250c29d16..3a56a23f63daf 100644 --- a/docs/operations/development.md +++ b/docs/operations/development.md @@ -167,7 +167,8 @@ of the affected packages and their dependents, and the test files beside the cha runs the checks that read other files: the agent context budget when the server is affected, and React Compiler coverage for only the changed web and mobile files. It maps files to packages with [ci-changes.cjs](../../.github/scripts/ci-changes.cjs), as CI does. It -compares against the merge base with `origin/main`, or with a ref you pass. CI still runs the full +compares against the closest merge base with any remote's `main`, so a fork whose `main` lags +upstream still compares against upstream, or with a ref you pass. CI still runs the full test suites and the jobs the summary lists, so a local pass does not promise a green CI run. The steps run side by side, as many at once as the machine allows: one per two cores and per 6 GiB diff --git a/scripts/check-changed.test.ts b/scripts/check-changed.test.ts index 2bcd623a0b4b6..b01c3556766c5 100644 --- a/scripts/check-changed.test.ts +++ b/scripts/check-changed.test.ts @@ -1,13 +1,26 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Option from "effect/Option"; import { assert, describe, it } from "@effect/vitest"; import * as Clock from "effect/Clock"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; -import { ChildProcessSpawner } from "effect/process"; +import { ChildProcess, ChildProcessSpawner } from "effect/process"; import * as TestClock from "effect/testing/TestClock"; -import { defaultConcurrency, gitLines, runSteps, type Step } from "./check-changed.ts"; +import { + comparisonBase, + defaultConcurrency, + gitLines, + runSteps, + type Step, +} from "./check-changed.ts"; const GiB = 1024 ** 3; @@ -152,3 +165,89 @@ describe("check:changed", () => { }); }); }); + +// Hooks export GIT_DIR and GIT_INDEX_FILE, and user config or the default +// ~/.config/git/ignore can ignore or sign fixture files. +const fixtureConfig = { + "core.excludesFile": NodeOS.devNull, + "user.name": "Fixture", + "user.email": "fixture@example.invalid", +}; +const env = { + ...Object.fromEntries(Object.entries(process.env).filter(([name]) => !name.startsWith("GIT_"))), + GIT_CONFIG_GLOBAL: NodeOS.devNull, + GIT_CONFIG_NOSYSTEM: "1", + GIT_CONFIG_COUNT: String(Object.keys(fixtureConfig).length), + ...Object.fromEntries( + Object.entries(fixtureConfig).flatMap(([key, value], index) => [ + [`GIT_CONFIG_KEY_${index}`, key], + [`GIT_CONFIG_VALUE_${index}`, value], + ]), + ), +}; + +// History A -> B -> C, with HEAD at C. Each remote's main points at A or B. +function fixture(remotes: Record) { + const root = NodeFS.realpathSync( + NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-check-changed-")), + ); + const commits: Record = {}; + try { + const write = (file: string, content: string) => { + const path = NodePath.join(root, file); + NodeFS.mkdirSync(NodePath.dirname(path), { recursive: true }); + NodeFS.writeFileSync(path, content); + }; + const git = (...args: Array) => + NodeChildProcess.execFileSync("git", args, { cwd: root, encoding: "utf8", env }).trim(); + git("init"); + for (const name of ["A", "B", "C"]) { + write(`${name}.txt`, name); + git("add", "."); + git("commit", "-m", name); + commits[name] = git("rev-parse", "HEAD"); + } + for (const [remote, commit] of Object.entries(remotes)) { + git("remote", "add", remote, NodePath.join(root, "missing")); + git("update-ref", `refs/remotes/${remote}/main`, commits[commit]!); + } + } catch (error) { + NodeFS.rmSync(root, { recursive: true, force: true }); + throw error; + } + return { + commits, + root, + [Symbol.dispose]() { + NodeFS.rmSync(root, { recursive: true, force: true }); + }, + }; +} + +const compare = (root: string, ref: Option.Option) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + return yield* comparisonBase(root, ref).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => spawner.spawn(ChildProcess.setEnv(command, env))), + ), + ); + }).pipe(Effect.provide(NodeServices.layer), Effect.runPromise); + +it.each(["upstream", "team/upstream"])( + "compares against %s/main when the fork's origin/main lags it", + async (remote) => { + using project = fixture({ origin: "A", [remote]: "B" }); + const result = await compare(project.root, Option.none()); + assert.strictEqual(result.base, project.commits.B); + assert.strictEqual(result.ref, remote + "/main"); + }, +); + +it("compares against an explicit ref instead of the closest remote main", async () => { + using project = fixture({ origin: "A", upstream: "B" }); + const result = await compare(project.root, Option.some("origin/main")); + assert.strictEqual(result.base, project.commits.A); + assert.strictEqual(result.ref, "origin/main"); +}); diff --git a/scripts/check-changed.ts b/scripts/check-changed.ts index dfa4f3a833bbe..b23289211b90e 100644 --- a/scripts/check-changed.ts +++ b/scripts/check-changed.ts @@ -7,7 +7,7 @@ // A dependency change also gets the unused-patch install check below. CI still // runs the full test suites and the jobs listed in the summary. // -// vp run check:changed compare against the merge base with origin/main +// vp run check:changed compare against the closest merge base with any remote main // vp run check:changed compare against the merge base with // vp run check:changed --concurrency 1 run the steps one at a time // @@ -142,6 +142,35 @@ export const git = Effect.fn("checkChanged.git")(function* ( export const gitLines = (cwd: string, args: ReadonlyArray) => git(cwd, args).pipe(Effect.map((stdout) => stdout.toString().split("\n").filter(Boolean))); +export const comparisonBase = Effect.fn("checkChanged.comparisonBase")(function* ( + root: string, + explicitRef: Option.Option, +) { + const candidates = Option.isSome(explicitRef) + ? [{ ref: explicitRef.value, rev: explicitRef.value }] + : (yield* gitLines(root, ["remote"])).map((name) => ({ + ref: name + "/main", + rev: "refs/remotes/" + name + "/main", + })); + let closest: { ref: string; base: string; ahead: number } | undefined; + for (const { ref, rev } of candidates) { + const base = yield* git(root, ["merge-base", "HEAD", rev]).pipe( + Effect.map((stdout) => stdout.toString().trim()), + Effect.orElseSucceed(() => undefined), + ); + if (!base) continue; + const ahead = Number((yield* git(root, ["rev-list", "--count", base + "..HEAD"])).toString()); + if (!closest || ahead < closest.ahead) closest = { ref, base, ahead }; + } + if (!closest) { + const tried = candidates.map(({ ref }) => ref).join(", ") || "a remote main"; + return yield* Effect.fail( + "check:changed: no merge base with " + tried + ". Run git fetch or pass a ref.", + ); + } + return closest; +}); + const CODE = /\.[cm]?[jt]sx?$/; const TEST = /\.test\.[cm]?[jt]sx?$/; const escapeRegExp = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); @@ -162,7 +191,7 @@ const override = Effect.fn("checkChanged.override")(function* ( }); const checkChanged = Effect.fn("checkChanged")(function* (input: { - readonly ref: string; + readonly ref: Option.Option; readonly concurrency: Option.Option; readonly timeout: Option.Option; }) { @@ -190,23 +219,17 @@ const checkChanged = Effect.fn("checkChanged")(function* (input: { const lines = (...args: Array) => gitLines(root, args); - const base = yield* git(root, ["merge-base", "HEAD", input.ref]).pipe( - Effect.map((stdout) => stdout.toString().trim()), - Effect.mapError( - () => - `check:changed: no merge base with ${input.ref}. Run \`git fetch origin\` or pass a ref.`, - ), - ); + const { ref, base } = yield* comparisonBase(root, input.ref); // --no-renames lists a rename as its old and new path, as CI does. const changedFiles = lines("diff", "--name-only", "--no-renames", base, "--"); const untrackedFiles = lines("ls-files", "--others", "--exclude-standard"); const files = [...new Set([...(yield* changedFiles), ...(yield* untrackedFiles)])].sort(); if (files.length === 0) { - yield* Console.log(`check:changed: no changes since ${base.slice(0, 12)}.`); + yield* Console.log(`check:changed: no changes since ${base.slice(0, 12)} (${ref}).`); return true; } - yield* Console.log(`check:changed: ${files.length} files since ${base.slice(0, 12)}`); + yield* Console.log(`check:changed: ${files.length} files since ${base.slice(0, 12)} (${ref})`); // Ignored generated sources, declarations, env files, and install state affect check results. // Include them in the key, but skip large ignored folders that no check reads. @@ -521,7 +544,7 @@ export const checkChangedCommand = Command.make( { ref: Argument.String("ref").pipe( Argument.withDescription("Compare against the merge base with this ref."), - Argument.withDefault("origin/main"), + Argument.optional, ), concurrency: Flag.Int("concurrency").pipe( Flag.withDescription(