Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/operations/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
103 changes: 101 additions & 2 deletions scripts/check-changed.test.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -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<string, "A" | "B">) {
const root = NodeFS.realpathSync(
NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-check-changed-")),
);
const commits: Record<string, string> = {};
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<string>) =>
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<string>) =>
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");
});
47 changes: 35 additions & 12 deletions scripts/check-changed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <ref> compare against the merge base with <ref>
// vp run check:changed --concurrency 1 run the steps one at a time
//
Expand Down Expand Up @@ -142,6 +142,35 @@ export const git = Effect.fn("checkChanged.git")(function* (
export const gitLines = (cwd: string, args: ReadonlyArray<string>) =>
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<string>,
) {
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, "\\$&");
Expand All @@ -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<string>;
readonly concurrency: Option.Option<number>;
readonly timeout: Option.Option<number>;
}) {
Expand Down Expand Up @@ -190,23 +219,17 @@ const checkChanged = Effect.fn("checkChanged")(function* (input: {

const lines = (...args: Array<string>) => 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.
Expand Down Expand Up @@ -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(
Expand Down
Loading