Skip to content
Open
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
1 change: 0 additions & 1 deletion quest/m0/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,6 @@ ownership-manifest design:
- [Local mergeability](/quest/m0/land-mergeability.md) - `quest land` merges with the quest driver instead of ejecting on GitHub's conflict flag
- [Accurate head_moved](/quest/m0/land-head-moved.md) - the gate's own base-merge push and batch-mate failures are not reported as a moved head
- [Queue-time checks](/quest/m0/land-queue-checks.md) - queued PRs are checked against each other and `quest check` runs per PR before batching
- [Drop emptied Required headings](/quest/m0/required-heading.md) - a clean driver merge that empties a `Required` list passes `quest check`
- [Codex cloud off](/quest/m0/codex-cloud-off.md) - automatic Codex cloud reviews stop once no open PR relies on them
- [Review handles expired and environment logins](/quest/m0/review-login.md) - `quest review` says to log in again instead of failing with a 401, and reviews with credentials from the environment
- [Live sandbox test](/quest/m0/review-sandbox.md) - the real reviewer CLIs run nothing a reviewed head plants
Expand Down
25 changes: 0 additions & 25 deletions quest/m0/required-heading.md

This file was deleted.

94 changes: 89 additions & 5 deletions src/core/merge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
// inserts a new quest next to them. Such a conflict merges item by item: keep
// what neither side removed and add what either side inserted. Anything else,
// including prose, a heading, or an item both sides rewrote, stays a conflict.
// A `Required` list the merge empties loses its heading too, since an empty
// one would block its quest forever.

import { parse } from "./doc";
import { LIST_SECTIONS } from "./rules";
Expand All @@ -27,9 +29,10 @@ export type Chunk = Clean | Conflict;
const SIDES = ["ours", "base", "theirs"] as const;
type Side = (typeof SIDES)[number];

/** A list entry as one side has it: the destination of its opening link, and its source lines. */
/** A list entry as one side has it: the destination of its opening link, its section, and its source lines. */
interface Item {
key: string;
section: string;
lines: string[];
}

Expand Down Expand Up @@ -103,18 +106,95 @@ export function render(chunks: Chunk[], size: number, diff3: boolean, labels = L
* removed, with the one side's edit when only one side changed one, and
* inserted are the entries either side added, after the entry that precedes
* them on that side, ours before theirs. Entries are matched by their opening
* link's destination. Every other conflict is returned as it was.
* link's destination. A `Required` section this leaves with nothing but
* blank lines is dropped with its heading. Every other conflict is returned as
* it was.
*/
export function resolveLists(path: string, merged: Chunk[]): Chunk[] {
const items = {
ours: lists(path, merged, "ours"),
base: lists(path, merged, "base"),
theirs: lists(path, merged, "theirs"),
};
return merged.map((chunk, index) => {
const emptied = new Set<number>();
const resolved = merged.map((chunk, index) => {
const [ours, base, theirs] = SIDES.map((side) => items[side].get(index));
if (ours === undefined || base === undefined || theirs === undefined) return chunk;
return mergeItems(ours, base, theirs) ?? chunk;
const lines = mergeItems(ours, base, theirs);
if (lines === null) return chunk;
if (lines.length === 0 && base.some((item) => item.section === "Required")) emptied.add(index);
return lines;
});
return emptied.size === 0 ? resolved : dropEmptyRequired(path, resolved, emptied);
}

/**
* `merged` without each `Required` heading whose section holds one of the
* `emptied` resolutions and otherwise only blank lines, with no conflict left
* in it, along with that section's lines. As `quest rm` does, a blank line
* left doubled by the removal goes too.
*/
function dropEmptyRequired(path: string, merged: Chunk[], emptied: Set<number>): Chunk[] {
// The document with every conflict taken from ours; `clean` marks lines outside a conflict.
// `at` and `open` hold where each emptied list and each remaining conflict
// sits, as the index of the line after it, since either can have no lines.
const lines: string[] = [];
const clean: boolean[] = [];
const at: number[] = [];
const open: number[] = [];
merged.forEach((chunk, index) => {
if (emptied.has(index)) at.push(lines.length);
if (!Array.isArray(chunk)) open.push(lines.length);
const own = Array.isArray(chunk) ? chunk : chunk.ours;
lines.push(...own);
clean.push(...own.map(() => Array.isArray(chunk)));
});
const doc = parse(path, lines.join("\n"));
// A trailing newline leaves an empty last element, which is not a line.
const count = lines.length > 0 && lines[lines.length - 1] === "" ? lines.length - 1 : lines.length;

// Indices, 0-based, of the lines to drop.
const dropped = new Set<number>();
doc.headings.forEach((heading, index) => {
if (heading.text !== "Required" || !clean[heading.line - 1]) return;
const next = doc.headings[index + 1];
if (next !== undefined && !clean[next.line - 1]) return;
// The section's body is the 0-based lines from `heading.line` up to `end`.
const end = next === undefined ? count : next.line - 1;
const inside = (line: number) => line >= heading.line && line <= end;
if (!at.some(inside) || open.some(inside)) return;
const body = Array.from({ length: end - heading.line }, (_, i) => heading.line + i);
if (!body.every((i) => lines[i].trim() === "")) return;
dropped.add(heading.line - 1);
for (const i of body) dropped.add(i);
});
if (dropped.size === 0) return merged;

// Removing a block between two blank lines would leave both; keep one.
let line = 0;
let gap = false;
let blank = false;
return merged.map((chunk) => {
if (!Array.isArray(chunk)) {
line += chunk.ours.length;
gap = false;
blank = false;
return chunk;
}
const kept: string[] = [];
for (const text of chunk) {
if (dropped.has(line++)) {
gap = true;
continue;
}
const empty = text.trim() === "";
if (!(gap && empty && blank)) {
kept.push(text);
blank = empty;
}
gap = false;
}
return kept;
});
}

Expand Down Expand Up @@ -156,7 +236,11 @@ function lists(path: string, merged: Chunk[], side: Side): Map<number, Item[]> {
return ok;
});
if (!valid || next !== last + 1) continue;
const items = entries.map((entry) => ({ key: entry.target!, lines: lines.slice(entry.line - 1, entry.end) }));
const items = entries.map((entry) => ({
key: entry.target!,
section: entry.section!,
lines: lines.slice(entry.line - 1, entry.end),
}));
if (items.every((item) => selfContained(path, item))) out.set(index, items);
}
return out;
Expand Down
48 changes: 47 additions & 1 deletion test/resolve.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { fileURLToPath } from "node:url";

import { describe, expect, test } from "vitest";

import { chunks, render, resolveLists } from "../src/core";
import { check, chunks, parse, render, resolveLists } from "../src/core";
import { type ResolveOptions, resolveFile } from "../src/cli/resolve";
import { heal, init, uninstall } from "../src/cli/setup";
import { tempDir } from "./fixture";
Expand Down Expand Up @@ -200,6 +200,52 @@ describe("resolve", () => {
conflicts: 0,
});
});

describe("a Required list both sides emptied", () => {
const plan = "# [S] Quest\n\n## Goal\n\nGoal.\n\n## Plan\n\nPlan.\n";
const related = `\n## Related\n\n${item("r")}\n`;
const empty = (text: string) =>
check([parse("quest/README.md", text)], () => true).filter((f) => f.message.includes("is empty"));

for (const [name, tail] of [
["at the end", ""],
["before another section", related],
] as const) {
test(`loses its heading ${name}`, () => {
const base = `${quest([item("a"), item("b")])}${tail}`;
// Each side finished one of the last two blockers.
const { text, conflicts } = merge(base, `${quest([item("b")])}${tail}`, `${quest([item("a")])}${tail}`);
expect({ text, conflicts }).toEqual({ text: `${plan}${tail}`, conflicts: 0 });
expect(empty(text)).toEqual([]);
});
}

test("loses its heading beside a conflict left in prose", () => {
const { text, conflicts } = merge(
quest([item("a"), item("b")], "Base plan."),
quest([item("b")], "Our plan."),
quest([item("a")], "Their plan."),
);
expect(conflicts).toBe(1);
expect(text).toContain("<<<<<<< ours\nOur plan.\n=======\nTheir plan.\n>>>>>>> theirs\n");
expect(text).not.toContain("## Required");
});

test("keeps its heading while a conflict in it remains", () => {
// Ours removing b against theirs editing it stays a conflict, even with no lines on ours.
const head = ["# [S] Quest", "", "## Goal", "", "Goal.", "", "## Required", ""];
const kept = { ours: [], base: [item("b")], theirs: [item("b", "edited")] };
const merged = [head, { ours: [], base: [item("a")], theirs: [] }, [""], kept, [""]];
expect(resolveLists("quest/README.md", merged)).toEqual([head, [], [""], kept, [""]]);
});

test("keeps a Related heading for quest check to report", () => {
const doc = (names: string[]) => `${plan}\n## Related\n\n${names.map((n) => item(n)).join("\n")}\n`;
const { text, conflicts } = merge(doc(["a", "b"]), doc(["b"]), doc(["a"]));
expect({ text, conflicts }).toEqual({ text: `${plan}\n## Related\n\n`, conflicts: 0 });
expect(empty(text)).toHaveLength(1);
});
});
});

const bin = fileURLToPath(new URL("../src/cli/bin.ts", import.meta.url));
Expand Down
Loading