From 85a1e48c26dff784cf3b9a1636d6a7fa9c63ec22 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 23 Oct 2026 23:04:11 -0700 Subject: [PATCH 1/3] chore(quest): claim quest/m0/helpers/resolve.md Quest-Claim: 6422ed0ec3f17c33 From 0afaebfc5c024378b2ad1f06b356544689d8254a Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 23 Oct 2026 23:19:45 -0700 Subject: [PATCH 2/3] feat(resolve): merge concurrent quest link-list edits with a git merge driver quest resolve merges by lines with git merge-file, then resolves each conflict that only edits the entries of one Required, Related, or Closes list: removals stay removed, inserts from either side are kept in place (ours first), and a one-sided edit wins. Anything else keeps conflict markers. quest init writes quest/** merge=quest to .gitattributes and sets merge.quest.driver; any other command registers the driver when the attributes name it, and uninstall removes both. Without a quest that has resolve on PATH, the driver falls back to git merge-file. Co-Authored-By: Claude Opus 5.5 --- .gitattributes | 2 + README.md | 17 +++ SETUP.md | 9 +- assets/AGENTS.md | 4 +- quest/m0/helpers/README.md | 1 - quest/m0/helpers/resolve.md | 56 -------- scripts/lifecycle-check.mjs | 10 ++ src/cli/main.ts | 34 ++++- src/cli/resolve.ts | 70 ++++++++++ src/cli/setup.ts | 55 +++++++- src/core/index.ts | 1 + src/core/merge.ts | 231 ++++++++++++++++++++++++++++++++ test/resolve.test.ts | 253 ++++++++++++++++++++++++++++++++++++ 13 files changed, 677 insertions(+), 66 deletions(-) delete mode 100644 quest/m0/helpers/resolve.md create mode 100644 src/cli/resolve.ts create mode 100644 src/core/merge.ts create mode 100644 test/resolve.test.ts diff --git a/.gitattributes b/.gitattributes index b9528234..7203c5eb 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,4 @@ # Keep bundled Markdown frontmatter and workflow scripts stable on every host. * text=auto eol=lf + +quest/** merge=quest diff --git a/README.md b/README.md index 65f9531c..ffee99ff 100644 --- a/README.md +++ b/README.md @@ -247,6 +247,23 @@ branch or open PR on `origin`, or on the repository a GitHub fork came from, since the branch would no longer name it. Neither command touches branches or PRs. +## Merge quest lists + +Concurrent PRs edit the same link lists: the base branch deletes finished +quests' lines while a branch inserts a new quest beside them. `quest init` +routes `quest/**` through a merge driver in `.gitattributes` and registers it +in the clone's git config, so a local `git merge` resolves those conflicts. + +Inside a conflicting `Required`, `Related`, or `Closes` list, `quest resolve` +keeps the entries neither side removed and adds the ones either side +inserted, ours before theirs at the same place, matching entries by their +opening link. Prose, headings, an entry both sides rewrote, or one side +removed while the other edited, keep the usual conflict markers. Any `quest` +command registers the driver in a fresh clone or worktree whose +`.gitattributes` names it. Without `quest` on the merge's PATH, the driver +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. + ## Wait for pull request activity `quest wait` blocks until a GitHub PR has a review, comment, review-thread diff --git a/SETUP.md b/SETUP.md index 40282278..1cda6f49 100644 --- a/SETUP.md +++ b/SETUP.md @@ -46,7 +46,10 @@ quest init Init writes a stub per skill under both `.claude/skills/` and `.agents/skills/` without creating symbolic links (existing linked layouts continue to work), creates `quest/README.md` when missing, and appends a line starting with `Quests: ` to the root `AGENTS.md`, or `CLAUDE.md` when only that -exists. It prints each path it changed and is safe to rerun. +exists. It also appends `quest/** merge=quest` to `.gitattributes` and sets +`merge.quest.driver` in the clone's git config, so local merges resolve +concurrent edits to quest link lists. It prints each path it changed and is +safe to rerun. If init refuses, it names the conflicting path. Report it and ask the user how to proceed; never delete or rename their files to get past it. @@ -82,8 +85,8 @@ work or `/quest-import` to bring in open GitHub issues. 1. Run `quest skill export` and follow it, so active quests become GitHub issues. Skip this only if the user says so. -2. Run `quest uninstall`. It removes only unmodified stubs and the `Quests: ` - line, and never deletes the quest tree. +2. Run `quest uninstall`. It removes only unmodified stubs, the `Quests: ` + line, and the merge driver, and never deletes the quest tree. 3. Remove the pin added above: the mise entry, or the flake input and dev shell package. For an unpinned install, delete the `quest` binary the installer placed (`~/.local/bin/quest` by default, or `quest.exe` in the diff --git a/assets/AGENTS.md b/assets/AGENTS.md index 7ce72285..2e90a5e1 100644 --- a/assets/AGENTS.md +++ b/assets/AGENTS.md @@ -20,7 +20,9 @@ Quests are linked with root-absolute paths. Find/create/update any references by searching for the path. Finished quests are deleted; git history keeps them. -Merge conflicts are expected; resolve them by aligning quests. +Merge conflicts are expected. +A local merge resolves conflicting edits to link lists itself when `quest` is on PATH; GitHub's cannot, so merge the base branch locally and push. +Resolve the rest, such as prose, by aligning quests. ## Format diff --git a/quest/m0/helpers/README.md b/quest/m0/helpers/README.md index 05e577f1..01e63ed5 100644 --- a/quest/m0/helpers/README.md +++ b/quest/m0/helpers/README.md @@ -62,7 +62,6 @@ commands from Sep-Oct. Session counts in each child come from that window. - [Status holds](/quest/m0/helpers/status-holds.md) - `quest status` shows a PR's `quest:*` hold - [Headless agents](/quest/m0/helpers/headless-agents.md) - `/quest-spawn api` launches, resumes, and stops billed agents through one command auto mode allows - [Claim ref conflicts](/quest/m0/helpers/claim-ref-conflict.md) - no skill branch blocks a claim, and `quest start` names a ref that does -- [Resolve](/quest/m0/helpers/resolve.md) - a local merge resolves concurrent link-list edits in quest files, set up by `quest init` ## Related diff --git a/quest/m0/helpers/resolve.md b/quest/m0/helpers/resolve.md deleted file mode 100644 index 169a8a88..00000000 --- a/quest/m0/helpers/resolve.md +++ /dev/null @@ -1,56 +0,0 @@ -# [M] Resolve quest-list conflicts with a merge driver - -## Goal - -A local `git merge` resolves concurrent edits to a quest's link lists -(`Required`, `Related`, `Closes`) without conflict markers: links either side -added are kept, links either side removed stay removed, and anything that is -not a pure link-list edit still conflicts as usual. Repositories get the -driver from `quest init` with no per-repository wiring. - -## Plan - -Decided 2026-10-23 (user), from a replay of 280 merges of `main` into PR -branches in moq-dev/moq since 2026-09-23: 128 conflicted, and 29 of those -conflicted only in quest files, 26 of them in milestone `Required` lists -(`quest/m1/README.md` has 88 entries). The usual shape is `main` deleting -finished quests' lines while a branch inserts a new quest next to them. - -- **Name (user):** `quest resolve`, registered as `merge.quest.driver`. - Rejected: `reconcile` and `merge-file`. -- **Missing binary falls back to git.** Git runs the driver from whatever - PATH the merge has, which may lack `quest` (a `bun run` checkout, `nix - run`, a shell outside the dev shell). A driver that is not found exits - non-zero with the file left as ours and no markers, which reads as - resolved. The registered command is a `sh -c` wrapper that runs - `quest resolve` when `quest resolve --help` succeeds and otherwise - `git merge-file -L ours -L base -L theirs %A %O %B`, so a missing binary, - or an older one without `resolve` during a staggered upgrade, degrades to - today's conflict markers. `%A`, `%O`, and `%B` are git's temporary file - names, so only `%P`, if the driver takes it, needs quoting. Rejected: persisting an absolute - path, which a version bump or Nix store change leaves stale. -- **Three-way list merge, not union.** Git's built-in `merge=union` was - rejected after the same replay: in nearly every one of 47 resolved merges it - kept lines for quests `main` had already deleted, because it concatenates - both sides of a hunk. Inside a conflicting link list, keep base items that - neither side removed, add items either side inserted, and order inserts by - their position on each side (ours before theirs at the same rank). It reads - only the three file versions, never the worktree. -- **Fail closed.** A conflict hunk that edits prose, changes an existing - item's text on both sides, or spans outside a link list is written with - standard conflict markers and the driver exits non-zero, so git reports the - conflict as it does today. -- **Registration (user):** `quest init` writes `quest/** merge=quest` to - `.gitattributes` and sets `merge.quest.driver` in the clone's local git - config. Any other `quest` command adds the config line when it is missing, - so fresh clones, worktrees, and new agents get it without a dev-shell hook. - Every config write, `init`'s included, happens only inside a git work - tree (the self-healing one also needs `.gitattributes` to name - `merge=quest`), and a failed write never fails the command, so `guide`, - `skill`, `gate`, `--root` runs outside a repository, and `init` before - `git init` are unaffected. - `quest uninstall` removes both. Git ignores drivers named only in - `.gitattributes`, and GitHub's own merges ignore drivers entirely, so a PR - still shows as conflicting until a local merge from its base is pushed. -- Update the guide's "Merge conflicts are expected" line to say list - conflicts resolve themselves and only prose conflicts need aligning. diff --git a/scripts/lifecycle-check.mjs b/scripts/lifecycle-check.mjs index e8e39aa6..3ea5f14d 100644 --- a/scripts/lifecycle-check.mjs +++ b/scripts/lifecycle-check.mjs @@ -76,6 +76,14 @@ try { execFileSync("git", ["-C", repository, "worktree", "add", "--quiet", "--detach", root]); run(root, "init"); assert.equal(run(root, "init"), ""); + // The merge driver probes for `resolve` with `quest help resolve`, which fails for an unknown command. + assertFile(root, ".gitattributes", "quest/** merge=quest\n"); + assert.match( + execFileSync("git", ["-C", root, "config", "merge.quest.driver"], { encoding: "utf8" }), + /resolve/, + ); + run(root, "help", "resolve"); + assert.equal(spawnSync(command, [...prefix, "help", "unknown"]).status, 2); for (const dir of destinations) { assert(lstatSync(join(root, dir)).isDirectory(), `${dir} must not require symlink privileges`); for (const skill of skills) @@ -113,6 +121,8 @@ try { assertFile(root, ".claude/skills/quest-plan/SKILL.md", edited); assertFile(root, ".agents/skills/quest-start/notes.md", "User notes.\n"); assert(!existsSync(join(root, ".agents/skills/quest-start/SKILL.md"))); + assert(!existsSync(join(root, ".gitattributes"))); + assert.equal(spawnSync("git", ["-C", root, "config", "merge.quest.driver"]).status, 1); for (const dir of originalDirs) assertFile(root, `${dir}/custom/SKILL.md`, `# Custom ${dir} workflow\n`); run(root, "check"); assert.equal(run(root, "uninstall"), ""); diff --git a/src/cli/main.ts b/src/cli/main.ts index 913501db..972fca3b 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -34,7 +34,8 @@ import { gitHubDependencies, GitHubInputError, mergedHead, pullRequest, remoteUr import { type Hold, type State, finish, land, release, take } from "./hold"; import { landed } from "./landed"; import { type Report, moveQuest, removeQuest } from "./move"; -import { init, uninstall } from "./setup"; +import { resolveFile } from "./resolve"; +import { heal, init, uninstall } from "./setup"; import { Refusal, describe, notUpstream, releaseQuest, startQuest } from "./start"; import { GUIDE, SKILLS, skill } from "./skills"; import { type Terminal, Theme } from "./theme"; @@ -799,6 +800,28 @@ export function main(argv: string[], out: Output): number | Promise { code = report(removeQuest(root(), path, options.recursive ?? false)); }); + program + .command("resolve") + .summary("Merge driver: resolve concurrent edits to a quest's link lists") + .description( + "Merge driver: merge into from by lines, as git merge-file does, and write the " + + "result to . A conflict that only edits the entries of one Required, Related, or Closes list is " + + "resolved: entries either side removed stay removed, entries either side added are kept in their " + + "place, ours before theirs, and an entry only one side changed takes that change. Entries match by " + + "their opening link. Any other conflict, including prose, a heading, an entry both sides changed, or a " + + "reordered list, keeps standard conflict markers, showing the base when merge.conflictStyle is diff3 " + + "or zdiff3. Exits 1 when a conflict remains.\n\n" + + "quest init registers it for quest/** as the merge.quest driver. Any other command registers the driver " + + "in the clone's config when .gitattributes names merge=quest and no config sets it. Without quest on " + + "the merge's PATH, the driver falls back to git merge-file. GitHub's merges ignore merge drivers.", + ) + .argument("", "Our version, overwritten with the result (git's %A)") + .argument("", "The common ancestor (git's %O)") + .argument("", "Their version (git's %B)") + .action((ours: string, base: string, theirs: string) => { + if (resolveFile(ours, base, theirs) > 0) code = 1; + }); + program .command("guide") .description("Print the quest guide: the format, workflow, and rules agents follow") @@ -827,7 +850,7 @@ export function main(argv: string[], out: Output): number | Promise { program .command("init") - .description("Install skill stubs, a quest root, and agent pointers for this repository") + .description("Install skill stubs, a quest root, agent pointers, and the merge driver for this repository") .action(() => { const changes = init(root()); for (const line of theme ? theme.init(changes) : changes) print(line); @@ -835,12 +858,17 @@ export function main(argv: string[], out: Output): number | Promise { program .command("uninstall") - .description("Remove Quest stubs and markers installed by `quest init`") + .description("Remove Quest stubs, markers, and the merge driver installed by `quest init`") .action(() => { const changes = uninstall(root()); for (const line of theme ? theme.uninstall(changes) : changes) print(line); }); + // Fresh clones and worktrees get the merge driver from whichever command runs first. + program.hook("preAction", (_program, action) => { + if (!["init", "uninstall"].includes(action.name())) heal(root()); + }); + try { program.parse(gateBoundary(argv), { from: "user" }); return pending ?? code; diff --git a/src/cli/resolve.ts b/src/cli/resolve.ts new file mode 100644 index 00000000..f50c53f2 --- /dev/null +++ b/src/cli/resolve.ts @@ -0,0 +1,70 @@ +// `quest resolve`: the merge driver for quest files. Git merges by line, then +// the core resolves the conflicts that only edit a link list. + +import { spawnSync } from "node:child_process"; +import { readFileSync, writeFileSync } from "node:fs"; + +import { LABELS, chunks, render, resolveLists } from "../core"; + +/** Git's default conflict marker length. */ +const MARKER_SIZE = 7; + +/** + * The command `merge.quest.driver` runs. Git substitutes its temporary file + * names for `%A`, `%O`, and `%B` and runs it with a shell. A `quest` missing + * from the merge's PATH, or one without `resolve`, falls back to git's own + * merge with conflict markers: `quest help ` exits non-zero, while + * `quest --help` prints the top-level help and exits zero. + */ +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`; + +/** + * 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 { + // 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 merged = spawnSync( + "git", + [ + "merge-file", + "-p", + "--diff3", + `--marker-size=${size}`, + "-L", + LABELS.ours, + "-L", + LABELS.base, + "-L", + LABELS.theirs, + ours, + base, + theirs, + ], + { encoding: "utf8", maxBuffer: 1 << 30 }, + ); + if (merged.error) throw merged.error; + // git merge-file exits with the number of conflicts, capped at 127, or above that on failure. + if (merged.status === null || merged.status > 127) { + 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())); + return resolved.filter((chunk) => !Array.isArray(chunk)).length; +} + +/** Whether unresolved conflicts show the base too, as `merge.conflictStyle` says. */ +function diff3(): boolean { + const style = spawnSync("git", ["config", "--get", "merge.conflictStyle"], { encoding: "utf8" }); + return ["diff3", "zdiff3"].includes(style.stdout?.trim() ?? ""); +} diff --git a/src/cli/setup.ts b/src/cli/setup.ts index caefdb7e..b943be8a 100644 --- a/src/cli/setup.ts +++ b/src/cli/setup.ts @@ -1,7 +1,8 @@ // `quest init` and `quest uninstall`: install skill stubs and reverse it. // -// Init touches only stubs, one reference line, and an empty `quest/README.md` -// when missing. Uninstall never deletes the quest tree. +// Init touches only stubs, one reference line, one `.gitattributes` line, an +// empty `quest/README.md` when missing, and the clone's merge driver config. +// Uninstall never deletes the quest tree. import { existsSync, @@ -18,6 +19,8 @@ import { } from "node:fs"; import { dirname, join, resolve } from "node:path"; +import { gitOutput } from "./claims"; +import { DRIVER } from "./resolve"; import { SKILLS } from "./skills"; /** @@ -32,6 +35,9 @@ export const REFERENCE_LINE = "Quests: when work mentions a quest, run `quest gu /** The skill directories Claude Code and Codex read; new installs use real directories. */ const SKILL_DIRS = [".claude/skills", ".agents/skills"] as const; +/** Routes quest files through the `quest` merge driver; uninstall removes lines starting with it. */ +export const ATTRIBUTES_LINE = "quest/** merge=quest"; + const QUEST_ROOT_README = "# Quests\n\n## Goal\n\nWhat this project is working toward.\n"; /** @@ -58,6 +64,7 @@ export function init(root: string): string[] { checkPath(root, readme, "file"); const instructions = ["AGENTS.md", "CLAUDE.md"].find((name) => isFile(join(root, name))) ?? "AGENTS.md"; checkPath(root, instructions, "file"); + checkPath(root, ".gitattributes", "file"); const changes: string[] = []; for (const { path, stub } of missing) { @@ -73,9 +80,51 @@ export function init(root: string): string[] { } if (appendLine(root, instructions, REFERENCE_MARKER, REFERENCE_LINE)) changes.push(instructions); + if (appendLine(root, ".gitattributes", ATTRIBUTES_LINE, ATTRIBUTES_LINE)) changes.push(".gitattributes"); + register(root); return changes; } +/** + * Register the merge driver when `root`'s `.gitattributes` names it, so fresh + * clones and worktrees get it from any command. Never fails. + */ +export function heal(root: string): void { + let named = false; + try { + named = lines(read(root, ".gitattributes")).some((line) => /(^|\s)merge=quest(\s|$)/.test(line)); + } catch { + // No attributes, so no driver to register. + } + if (named) register(root); +} + +/** + * Set `merge.quest.driver` in the clone's config when no config sets it, and + * only inside a git work tree: git ignores a driver named only in + * `.gitattributes`. A failed write is ignored, leaving git's own merge. + */ +function register(root: string): void { + try { + if (gitOutput(root, ["config", "--get", "merge.quest.driver"]) !== "") return; + if (gitOutput(root, ["rev-parse", "--is-inside-work-tree"]) !== "true") return; + gitOutput(root, ["config", "--local", "merge.quest.driver", DRIVER]); + } catch { + // Not a repository, or its config is not writable. + } +} + +/** Remove the merge driver from the clone's config, inside a git work tree. */ +function unregister(root: string): void { + try { + if (gitOutput(root, ["rev-parse", "--is-inside-work-tree"]) !== "true") return; + if (gitOutput(root, ["config", "--local", "--get", "merge.quest.driver"]) === "") return; + gitOutput(root, ["config", "--local", "--remove-section", "merge.quest"]); + } catch { + // Not a repository, or its config is not writable. + } +} + /** Check an install destination and its parents before any writes, following valid symlinks. */ function checkPath(root: string, path: string, expected: "directory" | "file"): void { const parent = dirname(path); @@ -126,6 +175,8 @@ export function uninstall(root: string): string[] { for (const path of ["AGENTS.md", "CLAUDE.md"]) { if (removeLine(root, path, REFERENCE_MARKER)) changes.push(path); } + if (removeLine(root, ".gitattributes", ATTRIBUTES_LINE)) changes.push(".gitattributes"); + unregister(root); return changes; } diff --git a/src/core/index.ts b/src/core/index.ts index 5eae59a9..87e8503d 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -31,3 +31,4 @@ export { comparePaths, normalize } from "./path"; export { type Blocker, blockers, label, lookup, ready, renderBlocker } from "./ready"; export { type Finding, ROOT, check, formatFinding } from "./rules"; export { type RankedQuest, unblocked } from "./unblocked"; +export { type Chunk, type Clean, type Conflict, LABELS, chunks, render, resolveLists } from "./merge"; diff --git a/src/core/merge.ts b/src/core/merge.ts new file mode 100644 index 00000000..725f1102 --- /dev/null +++ b/src/core/merge.ts @@ -0,0 +1,231 @@ +// Resolving three-way merge conflicts in a quest's link lists, for the +// `quest resolve` merge driver. +// +// Git's line merge already applies every edit only one side made. What is left +// are conflicts, and the common one in a quest tree is a link list both sides +// touched: the base branch deleting finished quests' lines while a branch +// 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. + +import { parse } from "./doc"; +import { LIST_SECTIONS } from "./rules"; + +/** Lines on which neither side conflicts, without their `\n`. */ +export type Clean = string[]; + +/** One conflicting region, as each version has it. */ +export interface Conflict { + ours: string[]; + base: string[]; + theirs: string[]; +} + +/** A file merged by lines: clean regions and conflicts, in order. */ +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. */ +interface Item { + key: string; + lines: string[]; +} + +/** Labels, in order, that the conflict markers carry; `quest resolve` asks git for these. */ +export const LABELS: Record = { ours: "ours", base: "base", theirs: "theirs" }; + +/** + * Split `git merge-file --diff3` output, written with `size`-character + * markers and `LABELS`, into chunks. + */ +export function chunks(text: string, size: number): Chunk[] { + const marker = (char: string, label?: string) => char.repeat(size) + (label === undefined ? "" : ` ${label}`); + const start = marker("<", LABELS.ours); + const base = marker("|", LABELS.base); + const split = marker("="); + const end = marker(">", LABELS.theirs); + + const out: Chunk[] = []; + let clean: Clean = []; + let conflict: Conflict | null = null; + let side: Side = "ours"; + for (const line of text.split("\n")) { + // Git writes markers with the file's own line ending. + const bare = line.endsWith("\r") ? line.slice(0, -1) : line; + if (conflict === null && bare === start) { + if (clean.length > 0) out.push(clean); + clean = []; + conflict = { ours: [], base: [], theirs: [] }; + side = "ours"; + } else if (conflict !== null && side === "ours" && bare === base) { + side = "base"; + } else if (conflict !== null && side === "base" && bare === split) { + side = "theirs"; + } else if (conflict !== null && side === "theirs" && bare === end) { + out.push(conflict); + conflict = null; + } else if (conflict !== null) { + conflict[side].push(line); + } else { + clean.push(line); + } + } + if (conflict !== null) throw new Error("unterminated conflict in merge output"); + out.push(clean); + return out; +} + +/** + * 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 { + const lines: string[] = []; + for (const chunk of chunks) { + if (Array.isArray(chunk)) { + lines.push(...chunk); + continue; + } + // 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}`); + } + return lines.join("\n"); +} + +/** + * Resolve every conflict that only edits the entries of one link list + * (`Required`, `Related`, or `Closes`). Kept are the base entries neither side + * 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. + */ +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 [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; + }); +} + +/** + * For the document with every conflict taken from `side`, the entries each + * conflict holds on that side, keyed by the conflict's index. A conflict is + * missing when its lines are anything but whole entries of one link list, + * each opening with a link. + */ +function lists(path: string, merged: Chunk[], side: Side): Map { + const lines: string[] = []; + const ranges = new Map(); + merged.forEach((chunk, index) => { + if (Array.isArray(chunk)) { + lines.push(...chunk); + } else { + ranges.set(index, [lines.length + 1, lines.length + chunk[side].length]); + lines.push(...chunk[side]); + } + }); + const doc = parse(path, lines.join("\n")); + + const out = new Map(); + for (const [index, [first, last]] of ranges) { + const entries = doc.entries.filter((entry) => entry.end >= first && entry.line <= last); + let next = first; + let section: string | null | undefined; + const valid = entries.every((entry) => { + // Entries tile the range: no line outside one, and none reaching past it. + const ok = + entry.line === next && + entry.end <= last && + entry.target !== null && + entry.section !== null && + LIST_SECTIONS.includes(entry.section) && + (section === undefined || section === entry.section); + next = entry.end + 1; + section = entry.section; + return ok; + }); + if (!valid || next !== last + 1) continue; + out.set( + index, + entries.map((entry) => ({ key: entry.target!, lines: lines.slice(entry.line - 1, entry.end) })), + ); + } + return out; +} + +/** The merged entries' lines, or `null` when the sides disagree on an entry or reorder the ones they share. */ +function mergeItems(ours: Item[], base: Item[], theirs: Item[]): string[] | null { + const [o, b, t] = [ours, base, theirs].map((items) => new Map(items.map((item) => [item.key, item]))); + if (o.size !== ours.length || b.size !== base.length || t.size !== theirs.length) return null; + + const kept: Item[] = []; + for (const item of base) { + const mine = o.get(item.key); + const other = t.get(item.key); + if (mine === undefined || other === undefined) { + // A removal wins only over a side that left the entry as it was. + const survivor = mine ?? other; + if (survivor !== undefined && !same(survivor, item)) return null; + continue; + } + if (same(mine, other) || same(other, item)) kept.push(mine); + else if (same(mine, item)) kept.push(other); + else return null; + } + + // Each side must keep the shared entries in base order: a reorder is a priority change. + const keys = new Set(kept.map((item) => item.key)); + const order = kept.map((item) => item.key).join("\n"); + for (const side of [ours, theirs]) { + if ( + side + .filter((item) => keys.has(item.key)) + .map((item) => item.key) + .join("\n") !== order + ) + return null; + } + + // Inserts, grouped under the kept entry that precedes them on their side. + const after = new Map(); + const inserted = new Map(); + for (const side of [ours, theirs]) { + let anchor: string | null = null; + for (const item of side) { + if (keys.has(item.key)) { + anchor = item.key; + continue; + } + if (b.has(item.key)) continue; + const twin = inserted.get(item.key); + if (twin !== undefined) { + if (!same(twin, item)) return null; + continue; + } + inserted.set(item.key, item); + after.set(anchor, [...(after.get(anchor) ?? []), item]); + } + } + + const out: string[] = []; + for (const anchor of [null, ...kept]) { + if (anchor !== null) out.push(...anchor.lines); + for (const item of after.get(anchor === null ? null : anchor.key) ?? []) out.push(...item.lines); + } + return out; +} + +function same(a: Item, b: Item): boolean { + return a.lines.join("\n") === b.lines.join("\n"); +} diff --git a/test/resolve.test.ts b/test/resolve.test.ts new file mode 100644 index 00000000..05d13358 --- /dev/null +++ b/test/resolve.test.ts @@ -0,0 +1,253 @@ +// `quest resolve`, the merge driver, and its registration by `quest init`. + +import { execFileSync, spawnSync } from "node:child_process"; +import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +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 { 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 } { + 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); + return { text: readFileSync(o, "utf8"), conflicts }; +} + +const quest = (required: string[], plan = "Plan.") => + `# [S] Quest\n\n## Goal\n\nGoal.\n\n## Plan\n\n${plan}\n\n## Required\n\n${required.join("\n")}\n`; +const item = (name: string, note = name) => `- [${name}](/quest/m1/${name}.md) - ${note}`; + +describe("resolve", () => { + test("keeps an insert beside entries the other side deleted", () => { + const base = quest(["a", "b", "c", "d"].map((n) => item(n))); + // main finished b and c while the branch inserted x next to them. + const ours = quest(["a", "d"].map((n) => item(n))); + const theirs = quest(["a", "b", "x", "c", "d"].map((n) => item(n))); + expect(merge(base, ours, theirs)).toEqual({ text: quest(["a", "x", "d"].map((n) => item(n))), conflicts: 0 }); + }); + + test("orders inserts at the same place ours first", () => { + const base = quest(["a", "b"].map((n) => item(n))); + const ours = quest(["a", "x", "y", "b"].map((n) => item(n))); + const theirs = quest(["a", "z", "b"].map((n) => item(n))); + expect(merge(base, ours, theirs)).toEqual({ + text: quest(["a", "x", "y", "z", "b"].map((n) => item(n))), + conflicts: 0, + }); + }); + + test("inserts the same entry once and keeps a one-sided edit", () => { + const base = quest(["a", "b", "c"].map((n) => item(n))); + const ours = quest([item("a", "edited"), item("x"), item("c")]); + const theirs = quest([item("a"), item("x"), item("b")]); + expect(merge(base, ours, theirs)).toEqual({ text: quest([item("a", "edited"), item("x")]), conflicts: 0 }); + }); + + test("merges wrapped entries whole", () => { + const wrapped = (name: string) => `${item(name)}\n that wraps onto a second line`; + const base = quest([wrapped("a"), wrapped("b"), item("c")]); + const ours = quest([wrapped("a"), item("c")]); + const theirs = quest([wrapped("a"), wrapped("b"), wrapped("x"), item("c")]); + expect(merge(base, ours, theirs)).toEqual({ + text: quest([wrapped("a"), wrapped("x"), item("c")]), + conflicts: 0, + }); + }); + + test("resolves CRLF lists", () => { + const crlf = (text: string) => text.replaceAll("\n", "\r\n"); + const base = crlf(quest(["a", "b", "c"].map((n) => item(n)))); + const ours = crlf(quest(["a", "c"].map((n) => item(n)))); + const theirs = crlf(quest(["a", "b", "x", "c"].map((n) => item(n)))); + expect(merge(base, ours, theirs)).toEqual({ + text: crlf(quest(["a", "x", "c"].map((n) => item(n)))), + conflicts: 0, + }); + }); + + for (const [name, ours, theirs] of [ + ["an entry both sides changed", [item("a", "ours"), item("b")], [item("a", "theirs"), item("c")]], + ["an entry one side changed and the other removed", [item("a", "edited"), item("b")], [item("c")]], + ["an entry without a link", ["- plain text", item("b")], [item("a"), item("c")]], + ] as const) { + test(`keeps markers for ${name}`, () => { + const base = quest([item("a"), item("b")]); + const { text, conflicts } = merge(base, quest([...ours]), quest([...theirs])); + expect(conflicts).toBe(1); + expect(text).toContain("<<<<<<< ours\n"); + expect(text).toContain(">>>>>>> theirs\n"); + }); + } + + test("keeps a conflict whose sides reorder the entries they share", () => { + const lines = (names: string[]) => names.map((n) => item(n)); + const conflict = { ours: lines(["b", "a"]), base: lines(["a", "b"]), theirs: lines(["a", "b", "c"]) }; + const merged = [["# [S] Quest", "", "## Goal", "", "Goal.", "", "## Required", ""], conflict, [""]]; + expect(resolveLists("quest/q.md", merged)).toEqual(merged); + // Git's line merge keeps a move it can apply, leaving only the inserts to conflict. + expect(merge(quest(lines(["a", "b"])), quest(lines(["b", "a"])), quest(lines(["a", "b", "c"])))).toEqual({ + text: quest(lines(["b", "a", "c"])), + conflicts: 0, + }); + }); + + test("diff3 output round trips through chunks and render", () => { + const text = "a\n<<<<<<< ours\nb\n||||||| base\nc\n=======\nd\n>>>>>>> theirs\ne\n"; + expect(render(chunks(text, 7), 7, true)).toBe(text); + expect(render(chunks(text, 7), 7, false)).toBe("a\n<<<<<<< ours\nb\n=======\nd\n>>>>>>> theirs\ne\n"); + }); + + test("resolves the list and keeps markers for prose", () => { + const base = quest(["a", "b", "c"].map((n) => item(n))); + 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 { text, conflicts } = merge(base, ours, theirs); + expect(conflicts).toBe(1); + expect(text).toContain("<<<<<<< ours\nOur plan.\n=======\nTheir plan.\n>>>>>>> theirs\n"); + expect(text).toContain(["a", "x", "c"].map((n) => item(n)).join("\n")); + }); + + test("keeps markers for a conflict that spans a heading", () => { + const base = `# [S] Quest\n\n## Goal\n\nGoal.\n\n## Required\n\n${item("a")}\n`; + const ours = `# [S] Quest\n\n## Goal\n\nGoal.\n`; + const theirs = `# [S] Quest\n\n## Goal\n\nGoal.\n\n## Required\n\n${item("a")}\n${item("x")}\n`; + expect(merge(base, ours, theirs).conflicts).toBe(1); + }); + + test("lists outside link sections are prose", () => { + const doc = (lines: string[]) => `# [S] Quest\n\n## Goal\n\nGoal.\n\n## Plan\n\n${lines.join("\n")}\n`; + const base = doc([item("a"), item("b")]); + expect(merge(base, doc([item("b")]), doc([item("a"), item("x"), item("b")])).conflicts).toBe(1); + }); + + test("content that looks like markers stays content", () => { + const base = quest( + ["a", "b"].map((n) => item(n)), + "Title\n=======", + ); + const ours = quest([item("b")], "Title\n======="); + const theirs = quest( + ["a", "x", "b"].map((n) => item(n)), + "Title\n=======", + ); + expect(merge(base, ours, theirs)).toEqual({ + text: quest( + ["x", "b"].map((n) => item(n)), + "Title\n=======", + ), + conflicts: 0, + }); + }); +}); + +const bin = fileURLToPath(new URL("../src/cli/bin.ts", import.meta.url)); +const bunfig = fileURLToPath(new URL("../bunfig.toml", import.meta.url)); + +function git(dir: string, ...args: string[]): string { + return execFileSync("git", ["-C", dir, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim(); +} + +/** A repository whose quest file has diverged on `main` and `branch`. */ +function diverged(): string { + const dir = tempDir(); + git(dir, "init", "-q", "-b", "main"); + git(dir, "config", "user.name", "Quest test"); + git(dir, "config", "user.email", "test@example.invalid"); + const write = (text: string) => { + mkdirSync(join(dir, "quest"), { recursive: true }); + writeFileSync(join(dir, "quest/README.md"), text); + git(dir, "add", "-A"); + git(dir, "commit", "-q", "-m", "edit"); + }; + write(quest(["a", "b", "c"].map((n) => item(n)))); + git(dir, "switch", "-q", "-c", "branch"); + write(quest(["a", "b", "x", "c"].map((n) => item(n)))); + git(dir, "switch", "-q", "main"); + write(quest(["a", "c"].map((n) => item(n)))); + 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"], { + encoding: "utf8", + env: { ...process.env, PATH: path }, + }); +} + +describe.skipIf(process.platform === "win32")("the merge driver", () => { + test("init registers it, and a local merge resolves link lists", () => { + const dir = diverged(); + init(dir); + expect(readFileSync(join(dir, ".gitattributes"), "utf8")).toBe("quest/** merge=quest\n"); + expect(git(dir, "config", "--get", "merge.quest.driver")).toContain("quest resolve %A %O %B"); + git(dir, "add", "-A"); + git(dir, "commit", "-q", "-m", "init"); + + const tools = tempDir(); + writeFileSync(join(tools, "quest"), `#!/bin/sh\nexec bun --config='${bunfig}' '${bin}' "$@"\n`); + chmodSync(join(tools, "quest"), 0o755); + const merged = mergeMain(dir, `${tools}:${process.env.PATH}`); + expect(merged.status, merged.stderr).toBe(0); + expect(readFileSync(join(dir, "quest/README.md"), "utf8")).toBe(quest(["a", "x", "c"].map((n) => item(n)))); + }); + + test("falls back to git's conflict markers without quest on PATH", () => { + const dir = diverged(); + init(dir); + git(dir, "add", "-A"); + git(dir, "commit", "-q", "-m", "init"); + 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); + } + const merged = mergeMain(dir, tools); + expect(merged.status).toBe(1); + expect(readFileSync(join(dir, "quest/README.md"), "utf8")).toContain("<<<<<<< ours\n"); + }); + + 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"); + heal(dir); + expect(git(dir, "config", "--get", "merge.quest.driver")).toContain("quest resolve"); + git(dir, "config", "merge.quest.driver", "custom"); + heal(dir); + expect(git(dir, "config", "--get", "merge.quest.driver")).toBe("custom"); + + uninstall(dir); + expect(readFileSync(join(dir, ".gitattributes"), "utf8")).toBe("* text=auto\n"); + expect(spawnSync("git", ["-C", dir, "config", "--get", "merge.quest.driver"]).status).toBe(1); + }); + + test("nothing registers without attributes or outside a work tree", () => { + const dir = diverged(); + heal(dir); + expect(spawnSync("git", ["-C", dir, "config", "--get", "merge.quest.driver"]).status).toBe(1); + + const plain = tempDir(); + writeFileSync(join(plain, ".gitattributes"), "quest/** merge=quest\n"); + heal(plain); + init(plain); + expect(readFileSync(join(plain, ".gitattributes"), "utf8")).toBe("quest/** merge=quest\n"); + }); +}); From 43a3bd4f26b8bffe1347a88ddd0fd4bd2778b7ba Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 24 Oct 2026 11:08:09 -0700 Subject: [PATCH 3/3] fix(resolve): keep conflicts on reference-style entries A reference-style link's destination lives in a definition outside the conflict, which the clean regions show only as merged. A side that re-targeted the entry there looked unchanged, so a remove on the other side silently dropped it. Resolve only entries whose own lines carry their destination. Co-Authored-By: Claude Opus 5.5 --- src/core/merge.ts | 18 +++++++++++++----- test/resolve.test.ts | 12 ++++++++++++ 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/src/core/merge.ts b/src/core/merge.ts index 725f1102..fd2cef43 100644 --- a/src/core/merge.ts +++ b/src/core/merge.ts @@ -122,7 +122,7 @@ export function resolveLists(path: string, merged: Chunk[]): Chunk[] { * For the document with every conflict taken from `side`, the entries each * conflict holds on that side, keyed by the conflict's index. A conflict is * missing when its lines are anything but whole entries of one link list, - * each opening with a link. + * each opening with an inline link. */ function lists(path: string, merged: Chunk[], side: Side): Map { const lines: string[] = []; @@ -156,14 +156,22 @@ function lists(path: string, merged: Chunk[], side: Side): Map { return ok; }); if (!valid || next !== last + 1) continue; - out.set( - index, - entries.map((entry) => ({ key: entry.target!, lines: lines.slice(entry.line - 1, entry.end) })), - ); + const items = entries.map((entry) => ({ key: entry.target!, lines: lines.slice(entry.line - 1, entry.end) })); + if (items.every((item) => selfContained(path, item))) out.set(index, items); } return out; } +/** + * Whether an entry's own lines name its destination. A reference-style link + * takes it from a definition outside the conflict, which the clean regions + * show only as merged, so a side that re-targeted the entry there would look + * unchanged. + */ +function selfContained(path: string, item: Item): boolean { + return parse(path, item.lines.join("\n")).entries[0]?.target === item.key; +} + /** The merged entries' lines, or `null` when the sides disagree on an entry or reorder the ones they share. */ function mergeItems(ours: Item[], base: Item[], theirs: Item[]): string[] | null { const [o, b, t] = [ours, base, theirs].map((items) => new Map(items.map((item) => [item.key, item]))); diff --git a/test/resolve.test.ts b/test/resolve.test.ts index 05d13358..e6665f47 100644 --- a/test/resolve.test.ts +++ b/test/resolve.test.ts @@ -89,6 +89,18 @@ describe("resolve", () => { }); } + test("keeps markers for an entry whose reference definition one side changed", () => { + // The definition merges cleanly, so the conflict alone cannot tell that theirs re-targeted a. + const doc = (required: string[], target: string) => + `${quest(required)}\n## Related\n\n${item("r")}\n\n[a]: ${target}\n`; + const base = doc(["- [a][a] - a", item("b")], "/quest/m1/a.md"); + const ours = doc([item("b")], "/quest/m1/a.md"); + const theirs = doc(["- [a][a] - a", item("x"), item("b")], "/quest/m1/replacement.md"); + const { text, conflicts } = merge(base, ours, theirs); + expect(conflicts).toBe(1); + expect(text).toContain("[a]: /quest/m1/replacement.md\n"); + }); + test("keeps a conflict whose sides reorder the entries they share", () => { const lines = (names: string[]) => names.map((n) => item(n)); const conflict = { ours: lines(["b", "a"]), base: lines(["a", "b"]), theirs: lines(["a", "b", "c"]) };