Public: anyone can read this; the crew can change it.

The merge queue says why a gate went red, and never bisects a merge conflict or an install failure (8-pull batch red in 6 s, then bisected) #6615

Merged opened this in answer to

Check · Review · Land · Live done — Landed and live in production.

Merged

torque/forge:torque/5276-the-merge-queue-says-why-a-gate-went-red → main 1 commit fork

  • Merged. torque/5276-the-merge-queue-says-why-a-gate-went-red was merged into main as 1cd06c3.

Review

Sign in to review this change.
7 files changed +329 −12
src/checks/gate_reason.ts added +71
@@ -0,0 +1,71 @@
1 /**
2 * Why a gate went red (#5276), read once where the gate ends and carried on
3 * the verdict, so the queue's log says it and acts on it:
4 *
5 * - `tests` — the check ran and named red tests: the code's, searched.
6 * - `typecheck` — tsc printed an error: the code's, searched.
7 * - `check` — the check step exited red and named neither: the code's,
8 * searched, as every red was before this.
9 * - `install` — the install step failed (#5093), `setup` — the gate failed
10 * before any step ran (export, history, a manifest it could not read),
11 * `timeout` — a step ran past its time. None of these says which pull is
12 * at fault: the queue gates once more and then calls it infrastructure,
13 * never red for a pull, and never searched.
14 *
15 * Plain data in, plain data out: no I/O here.
16 */
17
18 /** Where in its run the gate stopped red, as the check service saw it. */
19 export type GateStage = "setup" | "install" | "check" | "timeout";
20
21 export type GateReason =
22 | { readonly kind: "tests"; readonly failed: number }
23 | { readonly kind: "typecheck" }
24 | { readonly kind: "check" }
25 | { readonly kind: "install" }
26 | { readonly kind: "setup" }
27 | { readonly kind: "timeout" };
28
29 /** `src/x.ts(3,5): error TS2322: …` or `src/x.ts:3:5 - error TS2322: …`. */
30 const TSC_ERROR = /\berror TS\d+:/;
31
32 /** What a step's error says when it ran past its time (`apps/process.ts`). */
33 const TOOK_LONGER = /^\S+ took longer than \d+s/;
34
35 /**
36 * The reason of a red gate: its stage, and for the check step what its
37 * lines name. `failedTests` is how many red tests `failuresOf` read.
38 */
39 export function gateReason(stage: GateStage, error: string, lines: readonly string[], failedTests: number): GateReason {
40 if (stage === "timeout" || TOOK_LONGER.test(error)) return { kind: "timeout" };
41 if (stage === "install") return { kind: "install" };
42 if (stage === "setup") return { kind: "setup" };
43 if (failedTests > 0) return { kind: "tests", failed: failedTests };
44 if (lines.some((line) => TSC_ERROR.test(line))) return { kind: "typecheck" };
45 return { kind: "check" };
46 }
47
48 /** Whether a red with this reason is the code's, and so searched for its culprit. */
49 export function searched(reason: GateReason | undefined): boolean {
50 if (reason === undefined) return true;
51 return reason.kind === "tests" || reason.kind === "typecheck" || reason.kind === "check";
52 }
53
54 /** The reason in the queue's log: `install`, `typecheck`, `tests: 3 failed`. */
55 export function reasonWords(reason: GateReason | undefined): string {
56 if (reason === undefined) return "check";
57 return reason.kind === "tests" ? `tests: ${reason.failed} failed` : reason.kind;
58 }
59
60 /**
61 * A merge conflict in a batch, the earlier pull first and the one left out
62 * second: `merge-conflict (#5088 × #5090)`; `main × #5090` against the base.
63 */
64 export function conflictWords(earlier: number | undefined, pull: number): string {
65 return `merge-conflict (${earlier === undefined ? "main" : `#${earlier}`} × #${pull})`;
66 }
67
68 /** The stage of a check step that ended red: `timeout` when it ran past its time. */
69 export function checkStage(error: string): GateStage {
70 return TOOK_LONGER.test(error) ? "timeout" : "check";
71 }
src/checks/landing.ts +4 −1
@@ -26,6 +26,7 @@ import type { Pull } from "../core/pulls.ts";
2626 import type { Repo } from "../core/repos.ts";
2727 import { failuresOf } from "./failures.ts";
2828 import { keepForgeGate } from "./gate_last.ts";
29 import { gateReason } from "./gate_reason.ts";
2930 import { keepGateLog } from "./gate_log.ts";
3031 import { checksFor } from "./service.ts";
3132 import { landVerdict, type LandVerdict } from "./tiers.ts";
@@ -63,7 +64,9 @@ export async function gateCandidate(
6364 const kept = keepGateLog(app.config.dataDir, slug, commitOid, done.ok, done.lines, Date.now());
6465 const failed = failuresOf(done.lines);
6566 const link = kept === undefined ? undefined : `/api/v1/repos/${slug}/gates/${kept}/log`;
66 return landVerdict(done.ok, failed.tests, failed.files, link);
67 // Why it went red, said in the queue's log and read by its search (#5276).
68 const reason = done.ok ? undefined : gateReason(done.stage ?? "check", done.error, done.lines, failed.tests.length);
69 return landVerdict(done.ok, failed.tests, failed.files, link, reason);
6770 }
6871
6972 /**
src/checks/queue.ts +61 −5
@@ -42,6 +42,13 @@
4242 * green alone it rides at the tail of a batch, never at its head, and the
4343 * search finds it there if it is red on top of others.
4444 *
45 * Every red says why (#5276, `gate_reason.ts`): `tests: N failed`,
46 * `typecheck` or `check` is the code's and searched as above; `install`,
47 * `setup` or `timeout` names no pull's code, so the gate runs once more and
48 * red that way twice is infrastructure — no verdict, nothing handed back,
49 * nothing bisected. A pull that conflicts with an earlier one of its batch
50 * is never red: it is left out, the pair named, and the rest gate at once.
51 *
4552 * What is queued is not stored: it is the pulls `landIfGreen` admitted, and
4653 * every sweep admits them again. The queue only orders them and remembers
4754 * what is at the gate and what its searches found, for the tick line and
@@ -52,6 +59,7 @@ import type { Pull } from "../core/pulls.ts";
5259 import { runGit } from "../git/env.ts";
5360 import { mergeable, resolveOid, treeOf } from "../git/merge.ts";
5461 import { createLog } from "../kernel/log.ts";
62 import { conflictWords, reasonWords, searched } from "./gate_reason.ts";
5563 import type { LandVerdict } from "./tiers.ts";
5664
5765 /** The base a step must land on, and the tree it must write. */
@@ -457,10 +465,15 @@ function hhmm(ms: number): string {
457465 return new Date(ms).toISOString().slice(11, 16);
458466 }
459467
460 /** A gate's verdict in a word, for the log. */
468 /** A gate's verdict in a few words, for the log: a red says why (#5276). */
461469 function outcome(verdict: LandVerdict): string {
462470 if (verdict.lands) return "green";
463 return "interrupted" in verdict ? "cut off" : "red";
471 return "interrupted" in verdict ? "cut off" : `red (${reasonWords(verdict.reason)})`;
472 }
473
474 /** A red that names no pull's code — install, setup, timeout: gated once more, never searched. */
475 function machines(verdict: LandVerdict): verdict is Red {
476 return !verdict.lands && !("interrupted" in verdict) && !searched(verdict.reason);
464477 }
465478
466479 /** Gate runs a repository's line has made since boot. */
@@ -469,11 +482,32 @@ export function gateRuns(repoId: string): number {
469482 }
470483
471484 /**
472 * The line's gate: counted, logged as it starts and ends (a gate takes
485 * The line's gate (`attempt`), and once more when it was red for a reason
486 * that names no pull's code (#5276, #5093): an install, the gate's own
487 * setup, a timeout. Red that way twice is infrastructure — no verdict, as a
488 * gate cut off is not: no pull is handed back and nothing is searched.
489 */
490 function counted(line: Line, gate: GateCandidate): Gated {
491 const once = attempt(line, gate);
492 return async (candidate, signal) => {
493 const first = await once(candidate, signal);
494 if (!machines(first) || signal?.aborted === true) return first;
495 const again = await once(candidate, signal);
496 if (!machines(again)) return again;
497 log.warn(`gated ${pullsWord(candidate.steps.length)}: infrastructure (${reasonWords(again.reason)}, twice)`, {
498 repo: line.repoId,
499 pulls: candidate.steps.map((s) => `#${s.entry.pull.number}`).join(","),
500 });
501 return UNDECIDED;
502 };
503 }
504
505 /**
506 * One gate run: counted, logged as it starts and ends (a gate takes
473507 * minutes, #3430), and abandoned at the deadline or when `signal` aborts —
474508 * a speculative candidate discarded. Abandoned is no verdict.
475509 */
476 function counted(line: Line, gate: GateCandidate): Gated {
510 function attempt(line: Line, gate: GateCandidate): Gated {
477511 return async (candidate, signal) => {
478512 const beyond = line.flights.size > 0;
479513 const flight: InFlight = { pulls: candidate.steps.map((s) => s.entry.pull.id), since: Date.now() };
@@ -995,7 +1029,16 @@ async function build(batch: readonly Waiting[], on?: Candidate): Promise<Candida
9951029 const steps: Step[] = [];
9961030 for (const { entry } of batch) {
9971031 const merged = await mergeable(dir, tipOid, entry.pull.headOid);
998 if (!merged.ok || merged.value.treeOid === treeOid) continue;
1032 if (!merged.ok) {
1033 // Left out, never red (#5276): the rest gate without it, and once the
1034 // pull it clashes with lands, the conflict lane brings it up to date.
1035 if (merged.error.kind === "conflict")
1036 log.info(`left out of the batch: ${conflictWords(await clashesWith(dir, baseOid, steps, entry), entry.pull.number)}`, {
1037 files: merged.error.files.join(","),
1038 });
1039 continue;
1040 }
1041 if (merged.value.treeOid === treeOid) continue;
9991042 const commit = await runGit(
10001043 ["commit-tree", merged.value.treeOid, "-p", tipOid, "-m", `merge queue: #${entry.pull.number}`],
10011044 { cwd: dir, env: IDENTITY },
@@ -1008,3 +1051,16 @@ async function build(batch: readonly Waiting[], on?: Candidate): Promise<Candida
10081051 }
10091052 return { dir, baseOid, baseTreeOid, steps, tipOid, treeOid };
10101053 }
1054
1055 /**
1056 * The earlier step a pull that does not merge onto the chain clashes with:
1057 * the first whose prefix it does not merge onto. Undefined when it does not
1058 * merge onto the base itself. Read only on a conflict, so the extra merges
1059 * cost nothing on a clean batch.
1060 */
1061 async function clashesWith(dir: string, baseOid: string, steps: readonly Step[], entry: Queued): Promise<number | undefined> {
1062 if (!(await mergeable(dir, baseOid, entry.pull.headOid)).ok) return undefined;
1063 for (const step of steps)
1064 if (!(await mergeable(dir, step.commitOid, entry.pull.headOid)).ok) return step.entry.pull.number;
1065 return steps.at(-1)?.entry.pull.number;
1066 }
src/checks/service.ts +20 −6
@@ -92,6 +92,7 @@ import type { Result } from "../kernel/result.ts";
9292 import { placementFromEnv, type Placement } from "./placement.ts";
9393 import { JOB_VANISHED, runJobStep } from "./job_step.ts";
9494 import { checkInstallCache, installWithRetry, withLock } from "./install.ts";
95 import { checkStage, type GateStage } from "./gate_reason.ts";
9596 import { extraGates, gateQueued, gatesInFlight, watchGates } from "./queue.ts";
9697 import type { JobOptions } from "../cluster/jobs.ts";
9798 import type { CheckDetail } from "../core/runs.ts";
@@ -163,7 +164,7 @@ export interface ChecksHandle {
163164 pull: Pull,
164165 commitOid: string,
165166 signal?: AbortSignal,
166 ): Promise<{ ok: boolean; error: string; lines: string[]; interrupted?: boolean }>;
167 ): Promise<{ ok: boolean; error: string; lines: string[]; interrupted?: boolean; stage?: GateStage }>;
167168 /**
168169 * Stop checking. Checks under way get `drainMs` to finish unless `drain`
169170 * is false (a person's Ctrl-C): then they are cut off and queued again now.
@@ -716,7 +717,14 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle {
716717 x: Execution,
717718 ): Promise<
718719 | { ok: true; proven?: string }
719 | { ok: false; error: string; detail?: CheckDetail | undefined; infrastructure?: boolean }
720 | {
721 ok: false;
722 error: string;
723 detail?: CheckDetail | undefined;
724 infrastructure?: boolean;
725 /** Where it stopped red (#5276); none is before any step ran. */
726 stage?: GateStage;
727 }
720728 > {
721729 const { bare, repo, write, signal } = x;
722730 // A pull's fast check is of the pull as it would land: its head merged
@@ -966,7 +974,12 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle {
966974 { log: write, signal, retryMs: options.installRetryMs },
967975 );
968976 if (!installed.ok)
969 return { ok: false, error: installed.error, infrastructure: installed.infrastructure };
977 return {
978 ok: false,
979 error: installed.error,
980 infrastructure: installed.infrastructure,
981 stage: "install",
982 };
970983 }
971984 // The check step's own lines are kept beside what `write` already
972985 // logs, so a failure can be summarised once here — never by reading
@@ -1015,7 +1028,7 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle {
10151028 checkLines,
10161029 ran.error,
10171030 );
1018 return { ok: false, error: summary, detail };
1031 return { ok: false, error: summary, detail, stage: checkStage(ran.error) };
10191032 }
10201033 // Only a green check of the default branch is kept for the next.
10211034 if (
@@ -1251,6 +1264,7 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle {
12511264 signal: controller.signal,
12521265 });
12531266 if (done.ok) return { ok: true, error: "", lines };
1267 const stage = done.stage ?? "setup";
12541268 // Cut off by this forge stopping, the Job lost under it, or an
12551269 // install the machine failed twice: the code was not judged, so
12561270 // this is no red.
@@ -1260,8 +1274,8 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle {
12601274 done.error === JOB_VANISHED ||
12611275 done.infrastructure === true
12621276 )
1263 return { ok: false, error: done.error, lines, interrupted: true };
1264 return { ok: false, error: done.error, lines };
1277 return { ok: false, error: done.error, lines, interrupted: true, stage };
1278 return { ok: false, error: done.error, lines, stage };
12651279 } finally {
12661280 endGate(app.db, traced);
12671281 signal?.removeEventListener("abort", abandon);
src/checks/tiers.ts +5
@@ -9,6 +9,7 @@
99 * only decides.
1010 */
1111
12 import type { GateReason } from "./gate_reason.ts";
1213 import { GATE_FILE, needsFull, reach, reachLine, type Impact } from "./impact.ts";
1314
1415 /** A test under this many seconds is always in the fast tier. */
@@ -250,6 +251,8 @@ export type Judged =
250251 readonly comment: string;
251252 /** Joins the pull's fast set. */
252253 readonly learn: readonly string[];
254 /** Why the gate went red (#5276); a red read without one is the code's. */
255 readonly reason?: GateReason;
253256 };
254257
255258 /**
@@ -271,6 +274,7 @@ export function landVerdict(
271274 failedTests: readonly string[],
272275 failedFiles: readonly string[] = [],
273276 logLink?: string,
277 reason?: GateReason,
274278 ): Judged {
275279 if (green) return { lands: true };
276280 const names = failedTests.map((name) => `- ${name}`).join("\n");
@@ -283,5 +287,6 @@ export function landVerdict(
283287 ? `The full gate on land was red and named no test; the pull is open again.${kept}`
284288 : `The full gate on land was red; the pull is open again. Red tests:\n${names}${kept}`,
285289 learn: [...new Set(failedFiles)],
290 ...(reason === undefined ? {} : { reason }),
286291 };
287292 }
test/check-tiers.test.ts +2
@@ -575,6 +575,8 @@ describe("the tiers in the forge's own checks service", () => {
575575 const pull = listPulls(app.db, repo.id, { state: "open", limit: 5 })[0]!;
576576 const done = await checks.gate(repo, pull, red.value.commitOid);
577577 expect(done.ok).toBe(false);
578 // Red in the check step, not before it: the queue searches it (#5276).
579 expect(done.stage).toBe("check");
578580 const failed = failuresOf(done.lines);
579581 expect(failed).toEqual({ tests: ["boom"], files: ["test/x.test.ts"] });
580582 const verdict = landVerdict(false, failed.tests, failed.files);
test/merge-queue-reason.test.ts added +166
@@ -0,0 +1,166 @@
1 /**
2 * The merge queue says why a gate went red, and searches only a red that is
3 * the code's (#5276). An install, setup or timeout red is gated once more,
4 * then called infrastructure: no pull is handed back and nothing is
5 * bisected. A pull that conflicts with an earlier one of its batch is left
6 * out, named with it, and the rest land in that one gate.
7 */
8 import { afterAll, afterEach, beforeAll, describe, expect, spyOn, test } from "bun:test";
9 import { mkdtemp, rm } from "node:fs/promises";
10 import { tmpdir } from "node:os";
11 import { join } from "node:path";
12 import { checkStage, conflictWords, gateReason, reasonWords, searched } from "../src/checks/gate_reason.ts";
13 import { type Candidate, enqueue, type Expect, type Queued } from "../src/checks/queue.ts";
14 import { landVerdict, type LandVerdict } from "../src/checks/tiers.ts";
15 import type { Pull } from "../src/core/pulls.ts";
16
17 describe("gateReason reads why a gate went red", () => {
18 test("each stage and what the check printed", () => {
19 expect(gateReason("install", "install exited with code 1", [], 0)).toEqual({ kind: "install" });
20 expect(gateReason("setup", "the pull's base branch could not be found", [], 0)).toEqual({ kind: "setup" });
21 expect(gateReason("timeout", "", [], 0)).toEqual({ kind: "timeout" });
22 expect(gateReason("check", "check took longer than 7200s", [], 0)).toEqual({ kind: "timeout" });
23 expect(gateReason("check", "2 of 9 tests", ["(fail) a", "(fail) b"], 2)).toEqual({ kind: "tests", failed: 2 });
24 expect(gateReason("check", "x", ["src/a.ts(3,5): error TS2322: Type 'string' is not assignable"], 0)).toEqual({
25 kind: "typecheck",
26 });
27 expect(gateReason("check", "check exited with code 1", ["lint: bad"], 0)).toEqual({ kind: "check" });
28 });
29
30 test("words for the log, and which reds are searched", () => {
31 expect(reasonWords({ kind: "tests", failed: 3 })).toBe("tests: 3 failed");
32 expect(reasonWords({ kind: "install" })).toBe("install");
33 expect(reasonWords(undefined)).toBe("check");
34 expect(conflictWords(5088, 5090)).toBe("merge-conflict (#5088 × #5090)");
35 expect(conflictWords(undefined, 5090)).toBe("merge-conflict (main × #5090)");
36 for (const kind of ["tests", "typecheck", "check"] as const)
37 expect(searched(kind === "tests" ? { kind, failed: 1 } : { kind })).toBe(true);
38 for (const kind of ["install", "setup", "timeout"] as const) expect(searched({ kind })).toBe(false);
39 expect(searched(undefined)).toBe(true);
40 expect(checkStage("check took longer than 60s")).toBe("timeout");
41 expect(checkStage("check exited with code 1")).toBe("check");
42 });
43
44 test("the land verdict carries the reason", () => {
45 expect(landVerdict(false, [], [], undefined, { kind: "install" })).toMatchObject({ reopen: true, reason: { kind: "install" } });
46 expect(landVerdict(false, [], [])).not.toHaveProperty("reason");
47 });
48 });
49
50 describe("the merge queue acts on why a gate went red", () => {
51 let dir: string;
52 const told: number[] = [];
53 let logged: string[] = [];
54 const spies: { mockRestore(): void }[] = [];
55
56 const git = (...args: string[]) =>
57 Bun.spawnSync(["git", "-C", dir, "-c", "user.name=t", "-c", "user.email=t@t", ...args]).stdout.toString().trim();
58
59 beforeAll(async () => {
60 dir = await mkdtemp(join(tmpdir(), "forge-merge-queue-reason-"));
61 Bun.spawnSync(["git", "init", "-q", "-b", "main", dir]);
62 git("commit", "-q", "--allow-empty", "-m", "start");
63 for (const level of ["info", "warn"] as const)
64 spies.push(spyOn(console, level).mockImplementation((...args: unknown[]) => void logged.push(args.join(" "))));
65 });
66
67 afterEach(() => {
68 logged = [];
69 told.length = 0;
70 });
71
72 afterAll(async () => {
73 for (const s of spies) s.mockRestore();
74 await rm(dir, { recursive: true, force: true });
75 });
76
77 async function headWriting(branch: string, file: string, content = `${file}\n`): Promise<string> {
78 git("checkout", "-q", "-B", branch, "main");
79 git("reset", "-q", "--hard", "main");
80 await Bun.write(join(dir, file), content);
81 git("add", file);
82 git("commit", "-q", "-m", file);
83 git("checkout", "-q", "--detach");
84 return git("rev-parse", branch);
85 }
86
87 function entry(prefix: string, number: number, headOid: string): Queued {
88 return {
89 pull: { id: `${prefix}-${number}`, number, baseBranch: "main", headOid } as Pull,
90 dir,
91 land: async (expect: Expect) => {
92 if (git("rev-parse", "refs/heads/main") !== expect.baseOid) return undefined;
93 const tip = git("commit-tree", expect.treeOid, "-p", expect.baseOid, "-m", `#${number}`);
94 git("update-ref", "refs/heads/main", tip);
95 return tip;
96 },
97 release: () => {},
98 handBack: () => told.push(number),
99 };
100 }
101
102 const pullsOf = (c: Candidate) => c.steps.map((s) => s.entry.pull.number);
103 const red = (reason: NonNullable<Extract<LandVerdict, { reopen: true }>["reason"]>): LandVerdict => ({
104 lands: false,
105 reopen: true,
106 comment: "red",
107 learn: [],
108 reason,
109 });
110
111 test("an install red twice is infrastructure: gated twice, never bisected, no pull handed back", async () => {
112 const pulls: Queued[] = [];
113 for (let i = 1; i <= 4; i++) pulls.push(entry("install", i, await headWriting(`i${i}`, `i${i}.txt`)));
114 const gates: number[][] = [];
115 const gate = async (c: Candidate) => {
116 gates.push(pullsOf(c));
117 return red({ kind: "install" });
118 };
119 const answers = await enqueue("reason-install", pulls, gate, { depth: 1, room: () => 3 });
120 expect(gates).toEqual([[1, 2, 3, 4], [1, 2, 3, 4]]);
121 expect(told).toEqual([]);
122 expect(answers).toEqual([false, false, false, false]);
123 expect(logged.some((l) => l.includes("gated 4 pulls: red (install)"))).toBe(true);
124 expect(logged.some((l) => l.includes("gated 4 pulls: infrastructure (install, twice)"))).toBe(true);
125 });
126
127 test("an install red once, then green: the batch lands whole on the second gate", async () => {
128 const pulls: Queued[] = [];
129 for (let i = 11; i <= 13; i++) pulls.push(entry("flake", i, await headWriting(`j${i}`, `j${i}.txt`)));
130 let n = 0;
131 const gate = async (): Promise<LandVerdict> => (n++ === 0 ? red({ kind: "install" }) : { lands: true });
132 const answers = await enqueue("reason-flake", pulls, gate, { depth: 1, room: () => 0 });
133 expect(n).toBe(2);
134 expect(answers).toEqual([true, true, true]);
135 expect(told).toEqual([]);
136 });
137
138 test("a test red says how many failed and is still searched for its culprit", async () => {
139 const pulls: Queued[] = [];
140 for (let i = 21; i <= 22; i++) pulls.push(entry("tests", i, await headWriting(`k${i}`, i === 22 ? "bad.txt" : `k${i}.txt`)));
141 const gate = async (c: Candidate): Promise<LandVerdict> =>
142 git("ls-tree", "--name-only", c.tipOid).split("\n").includes("bad.txt") ? red({ kind: "tests", failed: 2 }) : { lands: true };
143 const answers = await enqueue("reason-tests", pulls, gate, { depth: 1, room: () => 0 });
144 expect(answers).toEqual([true, false]);
145 expect(told).toEqual([22]);
146 expect(logged.some((l) => l.includes("gated 2 pulls: red (tests: 2 failed)"))).toBe(true);
147 });
148
149 test("#b conflicting with #a is left out and named; everything else lands in one gate", async () => {
150 const a = entry("clash", 31, await headWriting("c31", "same.txt", "one\n"));
151 const x = entry("clash", 32, await headWriting("c32", "c32.txt"));
152 const b = entry("clash", 33, await headWriting("c33", "same.txt", "two\n"));
153 const y = entry("clash", 34, await headWriting("c34", "c34.txt"));
154 const gates: number[][] = [];
155 const gate = async (c: Candidate): Promise<LandVerdict> => {
156 gates.push(pullsOf(c));
157 return { lands: true };
158 };
159 const answers = await enqueue("reason-clash", [a, x, b, y], gate, { depth: 1, room: () => 3 });
160 expect(gates).toEqual([[31, 32, 34]]);
161 expect(answers).toEqual([true, true, false, true]);
162 expect(told).toEqual([]);
163 expect(logged.some((l) => l.includes("left out of the batch: merge-conflict (#31 × #33)"))).toBe(true);
164 expect(logged.some((l) => l.includes(": red"))).toBe(false);
165 });
166 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs