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
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,17 @@ command registers the driver in a fresh clone or worktree whose
falls back to `git merge-file`, and GitHub's merges ignore drivers, so a PR
shows as conflicting until a local merge from its base is pushed.

The registered driver passes Git's conflict marker length and revision labels
to `quest resolve`, including the ancestor label for `diff3` conflicts. A direct
invocation defaults to seven-character markers and `ours`/`base`/`theirs`;
`--marker-size`, `--ours-label`, `--base-label`, and `--theirs-label` override
them. Labels must fit on one line. The fallback carries the same Git metadata
when `quest` is missing or too old to accept those options.

Existing configured drivers are preserved. For a checkout still using the old
quest-generated command, clear its local `merge.quest.driver` entry and run any
quest command to register the current one.

## Wait for pull request activity

`quest wait` blocks until a GitHub PR has a review, comment, review-thread
Expand Down
1 change: 0 additions & 1 deletion quest/m1/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,5 @@ additional login providers. This milestone does not gate the first release.
- [ChatGPT approval](/quest/m1/chatgpt-waitlist.md) - resolve hosted-use approval before implementation
- [Live inbox](/quest/m1/inbox-polish.md) - live agent cards, active quests on the log, runner choice for every job, expiring offers
- [Worker dev setup](/quest/m1/worker-dev.md) - per-server cookies, seeded raw output, one fake GitHub, each test run alone
- [Merge driver tuning](/quest/m1/resolve-tuning.md) - measure `quest resolve` on moq's history and match git's markers
- [Incremental history](/quest/m1/history-pass.md) - each history pass reads only what changed since the last
- [Gate owns descendants](/quest/m1/gate-owns-descendants.md) - cancelling `quest gate` leaves none of its command's processes behind, on Linux and macOS
20 changes: 0 additions & 20 deletions quest/m1/resolve-tuning.md

This file was deleted.

10 changes: 7 additions & 3 deletions src/cli/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ import { type Hold, type State, finish, land, release, take } from "./hold";
import { LAND_CHECK, checkQueue, parsePlan, parseResult, pushQueue, resolveQueue } from "./land";
import { landed } from "./landed";
import { type Report, moveQuest, removeQuest } from "./move";
import { resolveFile } from "./resolve";
import { type ResolveOptions, resolveFile } from "./resolve";
import { heal, init, uninstall } from "./setup";
import { Refusal, describe, notUpstream, releaseQuest, startQuest } from "./start";
import { GUIDE, SKILLS, skill } from "./skills";
Expand Down Expand Up @@ -953,8 +953,12 @@ export function main(argv: string[], out: Output): number | Promise<number> {
.argument("<ours>", "Our version, overwritten with the result (git's %A)")
.argument("<base>", "The common ancestor (git's %O)")
.argument("<theirs>", "Their version (git's %B)")
.action((ours: string, base: string, theirs: string) => {
if (resolveFile(ours, base, theirs) > 0) code = 1;
.option("--marker-size <size>", "Conflict marker length (git's %L; default: 7)", positiveInteger("marker-size"))
.option("--ours-label <label>", "Our conflict label (git's %X; default: ours)")
.option("--base-label <label>", "Ancestor conflict label (git's %S; default: base)")
.option("--theirs-label <label>", "Their conflict label (git's %Y; default: theirs)")
.action((ours: string, base: string, theirs: string, options: ResolveOptions) => {
if (resolveFile(ours, base, theirs, options) > 0) code = 1;
});

program
Expand Down
36 changes: 27 additions & 9 deletions src/cli/resolve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,20 +18,38 @@ const MARKER_SIZE = 7;
*/
export const DRIVER =
"if quest help resolve >/dev/null 2>&1; " +
`then quest resolve %A %O %B; ` +
`else git merge-file -L ${LABELS.ours} -L ${LABELS.base} -L ${LABELS.theirs} %A %O %B; fi`;
"then quest resolve %A %O %B --marker-size=%L --ours-label=%X --base-label=%S --theirs-label=%Y; " +
'quest_merge_status=$?; if [ "$quest_merge_status" -le 1 ]; then exit "$quest_merge_status"; fi; fi; ' +
"git merge-file --marker-size=%L -L %X -L %S -L %Y %A %O %B";

export interface ResolveOptions {
markerSize?: number;
oursLabel?: string;
baseLabel?: string;
theirsLabel?: string;
}

/**
* Merge `theirs` into `ours` from their common `base`, as `git merge-file`
* does, writing the result to `ours`. Returns how many conflicts remain.
*/
export function resolveFile(ours: string, base: string, theirs: string): number {
export function resolveFile(ours: string, base: string, theirs: string, options: ResolveOptions = {}): number {
const markerSize = options.markerSize ?? MARKER_SIZE;
if (!Number.isSafeInteger(markerSize) || markerSize <= 0)
throw new Error("marker size must be a positive safe integer");
const labels = {
ours: options.oursLabel ?? LABELS.ours,
base: options.baseLabel ?? LABELS.base,
theirs: options.theirsLabel ?? LABELS.theirs,
};
if (Object.values(labels).some((label) => /[\r\n]/.test(label)))
throw new Error("conflict labels must be single lines");
// Markers longer than any run of marker characters in the inputs cannot be mistaken for content.
const texts = [ours, base, theirs].map((file) => readFileSync(file, "utf8"));
const longest = Math.max(
...texts.flatMap((text) => text.split("\n").map((line) => /^([<|=>])\1*/.exec(line)?.[0].length ?? 0)),
);
const size = Math.max(MARKER_SIZE, longest + 1);
const size = Math.max(markerSize, longest + 1);

const merged = spawnSync(
"git",
Expand All @@ -41,11 +59,11 @@ export function resolveFile(ours: string, base: string, theirs: string): number
"--diff3",
`--marker-size=${size}`,
"-L",
LABELS.ours,
labels.ours,
"-L",
LABELS.base,
labels.base,
"-L",
LABELS.theirs,
labels.theirs,
ours,
base,
theirs,
Expand All @@ -58,8 +76,8 @@ export function resolveFile(ours: string, base: string, theirs: string): number
throw new Error(`git merge-file failed: ${merged.stderr.trim()}`);
}

const resolved = resolveLists(ours, chunks(merged.stdout, size));
writeFileSync(ours, render(resolved, MARKER_SIZE, diff3()));
const resolved = resolveLists(ours, chunks(merged.stdout, size, labels));
writeFileSync(ours, render(resolved, markerSize, diff3(), labels));
return resolved.filter((chunk) => !Array.isArray(chunk)).length;
}

Expand Down
18 changes: 9 additions & 9 deletions src/core/merge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,14 +38,14 @@ export const LABELS: Record<Side, string> = { ours: "ours", base: "base", theirs

/**
* Split `git merge-file --diff3` output, written with `size`-character
* markers and `LABELS`, into chunks.
* markers and the supplied labels (defaulting to `LABELS`), into chunks.
*/
export function chunks(text: string, size: number): Chunk[] {
export function chunks(text: string, size: number, labels = LABELS): Chunk[] {
const marker = (char: string, label?: string) => char.repeat(size) + (label === undefined ? "" : ` ${label}`);
const start = marker("<", LABELS.ours);
const base = marker("|", LABELS.base);
const start = marker("<", labels.ours);
const base = marker("|", labels.base);
const split = marker("=");
const end = marker(">", LABELS.theirs);
const end = marker(">", labels.theirs);

const out: Chunk[] = [];
let clean: Clean = [];
Expand Down Expand Up @@ -81,7 +81,7 @@ export function chunks(text: string, size: number): Chunk[] {
* Join chunks back into a file. A conflict is written with standard
* `size`-character markers, with its base section only when `diff3` is set.
*/
export function render(chunks: Chunk[], size: number, diff3: boolean): string {
export function render(chunks: Chunk[], size: number, diff3: boolean, labels = LABELS): string {
const lines: string[] = [];
for (const chunk of chunks) {
if (Array.isArray(chunk)) {
Expand All @@ -90,9 +90,9 @@ export function render(chunks: Chunk[], size: number, diff3: boolean): string {
}
// A conflict's lines keep their own `\r`, so its markers take the same ending.
const eol = [...chunk.ours, ...chunk.base, ...chunk.theirs].some((line) => line.endsWith("\r")) ? "\r" : "";
lines.push(`${"<".repeat(size)} ${LABELS.ours}${eol}`, ...chunk.ours);
if (diff3) lines.push(`${"|".repeat(size)} ${LABELS.base}${eol}`, ...chunk.base);
lines.push(`${"=".repeat(size)}${eol}`, ...chunk.theirs, `${">".repeat(size)} ${LABELS.theirs}${eol}`);
lines.push(`${"<".repeat(size)} ${labels.ours}${eol}`, ...chunk.ours);
if (diff3) lines.push(`${"|".repeat(size)} ${labels.base}${eol}`, ...chunk.base);
lines.push(`${"=".repeat(size)}${eol}`, ...chunk.theirs, `${">".repeat(size)} ${labels.theirs}${eol}`);
}
return lines.join("\n");
}
Expand Down
106 changes: 96 additions & 10 deletions test/resolve.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,23 @@ import { fileURLToPath } from "node:url";
import { describe, expect, test } from "vitest";

import { chunks, render, resolveLists } from "../src/core";
import { resolveFile } from "../src/cli/resolve";
import { type ResolveOptions, resolveFile } from "../src/cli/resolve";
import { heal, init, uninstall } from "../src/cli/setup";
import { tempDir } from "./fixture";

/** Merge three versions with the driver; the merged text and how many conflicts remain. */
function merge(base: string, ours: string, theirs: string): { text: string; conflicts: number } {
function merge(
base: string,
ours: string,
theirs: string,
options: ResolveOptions = {},
): { text: string; conflicts: number } {
const dir = tempDir();
const [o, b, t] = ["ours", "base", "theirs"].map((name) => join(dir, name));
writeFileSync(o, ours);
writeFileSync(b, base);
writeFileSync(t, theirs);
const conflicts = resolveFile(o, b, t);
const conflicts = resolveFile(o, b, t, options);
return { text: readFileSync(o, "utf8"), conflicts };
}

Expand Down Expand Up @@ -119,6 +124,35 @@ describe("resolve", () => {
expect(render(chunks(text, 7), 7, false)).toBe("a\n<<<<<<< ours\nb\n=======\nd\n>>>>>>> theirs\ne\n");
});

test("preserves requested labels and marker length while resolving list conflicts", () => {
const base = quest(
["a", "b", "c"].map((n) => item(n)),
"Base plan.",
);
const ours = quest(
["a", "c"].map((n) => item(n)),
"Our plan.",
);
const theirs = quest(
["a", "b", "x", "c"].map((n) => item(n)),
"Their plan.",
);
const result = merge(base, ours, theirs, {
markerSize: 11,
oursLabel: "HEAD",
baseLabel: "ancestor",
theirsLabel: "topic's-head",
});
expect(result.conflicts).toBe(1);
expect(result.text).toContain("<<<<<<<<<<< HEAD\n");
expect(result.text).toContain(">>>>>>>>>>> topic's-head\n");
expect(result.text).toContain(["a", "x", "c"].map((n) => item(n)).join("\n"));
});

test("rejects multiline labels before a conflict could be mistaken for clean text", () => {
expect(() => merge("base\n", "ours\n", "theirs\n", { oursLabel: "one\ntwo" })).toThrow("single lines");
});

test("resolves the list and keeps markers for prose", () => {
const base = quest(["a", "b", "c"].map((n) => item(n)));
const ours = quest(
Expand Down Expand Up @@ -176,7 +210,7 @@ function git(dir: string, ...args: string[]): string {
}

/** A repository whose quest file has diverged on `main` and `branch`. */
function diverged(): string {
function diverged(prose = false): string {
const dir = tempDir();
git(dir, "init", "-q", "-b", "main");
git(dir, "config", "user.name", "Quest test");
Expand All @@ -187,18 +221,33 @@ function diverged(): string {
git(dir, "add", "-A");
git(dir, "commit", "-q", "-m", "edit");
};
write(quest(["a", "b", "c"].map((n) => item(n))));
write(
quest(
["a", "b", "c"].map((n) => item(n)),
prose ? "Base plan." : "Plan.",
),
);
git(dir, "switch", "-q", "-c", "branch");
write(quest(["a", "b", "x", "c"].map((n) => item(n))));
write(
quest(
(prose ? ["a", "b", "c"] : ["a", "b", "x", "c"]).map((n) => item(n)),
prose ? "Our plan." : "Plan.",
),
);
git(dir, "switch", "-q", "main");
write(quest(["a", "c"].map((n) => item(n))));
write(
quest(
(prose ? ["a", "b", "c"] : ["a", "c"]).map((n) => item(n)),
prose ? "Their plan." : "Plan.",
),
);
git(dir, "switch", "-q", "branch");
return dir;
}

/** Merge `main` into the branch with `path` as PATH. */
function mergeMain(dir: string, path: string) {
return spawnSync("git", ["-C", dir, "merge", "-q", "--no-edit", "main"], {
function mergeMain(dir: string, path: string, branch = "main") {
return spawnSync("git", ["-C", dir, "merge", "-q", "--no-edit", branch], {
encoding: "utf8",
env: { ...process.env, PATH: path },
});
Expand Down Expand Up @@ -234,9 +283,46 @@ describe.skipIf(process.platform === "win32")("the merge driver", () => {
}
const merged = mergeMain(dir, tools);
expect(merged.status).toBe(1);
expect(readFileSync(join(dir, "quest/README.md"), "utf8")).toContain("<<<<<<< ours\n");
expect(readFileSync(join(dir, "quest/README.md"), "utf8")).toContain("<<<<<<< HEAD\n");
});

for (const mode of ["quest", "missing", "older"] as const) {
test(`matches native diff3 labels and custom marker size with ${mode} quest`, () => {
const dir = diverged(true);
init(dir);
writeFileSync(join(dir, ".gitattributes"), "quest/** merge=quest conflict-marker-size=13\n");
git(dir, "config", "merge.conflictStyle", "diff3");
git(dir, "add", "-A");
git(dir, "commit", "-q", "-m", "init");
git(dir, "branch", "-m", "main", "their'branch");
// Capture git's built-in output for exactly these commits, then replay with our driver.
const attributes = join(dir, ".git/info/attributes");
writeFileSync(attributes, "quest/** merge=text\n");
expect(mergeMain(dir, process.env.PATH!, "their'branch").status).toBe(1);
const expected = readFileSync(join(dir, "quest/README.md"), "utf8");
git(dir, "merge", "--abort");
writeFileSync(attributes, "");
const tools = tempDir();
for (const tool of ["git", "sh"]) {
const found = execFileSync("sh", ["-c", `command -v ${tool}`], { encoding: "utf8" }).trim();
writeFileSync(join(tools, tool), `#!/bin/sh\nexec '${found}' "$@"\n`);
chmodSync(join(tools, tool), 0o755);
}
if (mode !== "missing") {
writeFileSync(
join(tools, "quest"),
mode === "older"
? '#!/bin/sh\nif [ "$1" = help ]; then exit 0; fi\nexit 2\n'
: `#!/bin/sh\nexec '${process.execPath}' --config='${bunfig}' '${bin}' "$@"\n`,
);
chmodSync(join(tools, "quest"), 0o755);
}
const result = mergeMain(dir, mode === "quest" ? `${tools}:${process.env.PATH}` : tools, "their'branch");
expect(result.status, result.stderr).toBe(1);
expect(readFileSync(join(dir, "quest/README.md"), "utf8")).toBe(expected);
});
}

test("any command registers a driver .gitattributes names, and uninstall removes it", () => {
const dir = diverged();
writeFileSync(join(dir, ".gitattributes"), "* text=auto\nquest/** merge=quest\n");
Expand Down
Loading