From b4043e5ea65c898c6d1b052e39167e7433df543d Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 2 Nov 2026 19:42:56 -0800 Subject: [PATCH 1/3] chore(quest): claim quest/m0/merge-driver-upkeep.md Quest-Claim: 84012bb4359c43fa From e722ffba84400fea99478f3bbac24b10f990bfd6 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 2 Nov 2026 19:52:26 -0800 Subject: [PATCH 2/3] feat(check): drop links to finished quests after a local merge, and upgrade old merge drivers quest check --fix drops every Required, Related, or Closes entry linking to a quest deleted in the history of HEAD (and MERGE_HEAD during a merge) that the working tree lacks, as quest rm does, so merging the base needs no hand edits for the quests it finished. Renames, mistyped links, and prose stay for the check to report; it refuses while a quest file is unmerged and leaves its edits unstaged. The guide's merge line points at it. Any quest command now replaces a merge.quest.driver an older quest registered with the current DRIVER, keeping custom drivers. Bump quest-pinned to current main, and give it its own Dependabot nix group without the cooldown so the dev shell's merge driver stays current. Finishes quest/m0/merge-driver-upkeep.md. Co-Authored-By: Claude Opus 5.5 --- .github/dependabot.yml | 5 ++ README.md | 14 ++++- assets/AGENTS.md | 1 + flake.lock | 6 +- flake.nix | 5 +- quest/m0/README.md | 1 - quest/m0/merge-driver-upkeep.md | 42 -------------- src/cli/main.ts | 17 ++++-- src/cli/move.ts | 48 ++++++++++++++- src/cli/resolve.ts | 15 +++++ src/cli/setup.ts | 12 ++-- test/move.test.ts | 100 +++++++++++++++++++++++++++++++- test/resolve.test.ts | 38 +++++++++++- 13 files changed, 238 insertions(+), 66 deletions(-) delete mode 100644 quest/m0/merge-driver-upkeep.md diff --git a/.github/dependabot.yml b/.github/dependabot.yml index dab4da27..6d84898c 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -30,7 +30,12 @@ updates: interval: weekly cooldown: default-days: 7 + # The dev shell's merge driver should carry quest's latest reviewed fixes. + exclude: ["quest-pinned"] groups: + # Its own PR, so a nixpkgs bump that needs work does not hold it back. + quest-pinned: + patterns: ["quest-pinned"] nix: patterns: ["*"] ignore: diff --git a/README.md b/README.md index 31c9c53c..01a3eecc 100644 --- a/README.md +++ b/README.md @@ -304,9 +304,17 @@ invocation defaults to seven-character markers and `ours`/`base`/`theirs`; 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. +A driver an older `quest` registered is replaced with the current one by the +next `quest` command; any other configured driver is preserved. + +A clean merge can still leave this branch's list links to quests the base +finished, which `quest check` then flags. `quest check --fix` drops every +`Required`, `Related`, or `Closes` entry linking to a quest deleted in the +history of `HEAD`, and of `MERGE_HEAD` during a merge, that the working tree +lacks, and prints `M path` for each edited document. It leaves the edits +unstaged and refuses while a quest file has unresolved conflicts. A quest moved +rather than deleted, a link that never named a quest, and a link in prose stay +for `quest check` to report. ## Wait for pull request activity diff --git a/assets/AGENTS.md b/assets/AGENTS.md index ca9034ec..47945df1 100644 --- a/assets/AGENTS.md +++ b/assets/AGENTS.md @@ -22,6 +22,7 @@ Find/create/update any references by searching for the path. Finished quests are deleted; git history keeps them. 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. +After merging, `quest check --fix` drops list links to quests the base finished. Resolve the rest, such as prose, by aligning quests. ## Format diff --git a/flake.lock b/flake.lock index 1e0e32b9..b6989621 100644 --- a/flake.lock +++ b/flake.lock @@ -53,11 +53,11 @@ "quest-pinned": { "flake": false, "locked": { - "lastModified": 1793505952, - "narHash": "sha256-jDq0fsxzLGIu6FsFfIsnUgvb23Pb+Rja4KZwIxGa34I=", + "lastModified": 1793675453, + "narHash": "sha256-BDXwGw79jH2YeJ8JkiN50iirI1tUwv4HM1mb/zAWC+4=", "owner": "moq-dev", "repo": "quest", - "rev": "7a191e6197c691f477b4c55edba34cc425c3e77c", + "rev": "acb8682041c7c3774d715a68ba9ed9f5da25593b", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index 95c4a90c..57cef1a2 100644 --- a/flake.nix +++ b/flake.nix @@ -10,8 +10,9 @@ flake-utils.url = "github:numtide/flake-utils"; # A reviewed quest for the dev shell's merge driver, so local merges resolve # link lists without running the checkout's own, possibly unreviewed, code. - # Only its source is pinned, so the lock never chains older pins. Bump with - # `nix flake update quest-pinned`. + # Only its source is pinned, so the lock never chains older pins. Dependabot's + # weekly nix update bumps it in its own PR, without the cooldown; bump it by + # hand with `nix flake update quest-pinned`. quest-pinned = { url = "github:moq-dev/quest"; flake = false; diff --git a/quest/m0/README.md b/quest/m0/README.md index eaf5ced7..820b7264 100644 --- a/quest/m0/README.md +++ b/quest/m0/README.md @@ -100,7 +100,6 @@ ownership-manifest design: - [Agent workflow helpers](/quest/m0/helpers/README.md) - CLI commands for CI waits, PR status, claims, quest moves, and serialized merges - [Review the branch's own change](/quest/m0/review-merge-base.md) - `quest review` diffs against the merge base and accepts a detached `HEAD` - [Skill stub cleanup](/quest/m0/skill-stubs.md) - stray skill copies deleted, and `quest init` refreshes unedited outdated stubs -- [Merge driver upkeep](/quest/m0/merge-driver-upkeep.md) - local merges drop links to finished quests, old drivers upgrade, and `quest-pinned` stays current - [Worktree hooks here](/quest/m0/worktree-hooks.md) - `quest` reaches Claude Code's hooks and the merge driver in this repository, then `quest init` installs the hooks - [Other worktree routes](/quest/m0/worktree-routes.md) - live-check PR worktrees, agent view, remote-control, and workflow agents against the hooks - [Spotlight off in macOS CI](/quest/m0/ci-spotlight.md) - this repository's macOS jobs turn off Spotlight indexing first diff --git a/quest/m0/merge-driver-upkeep.md b/quest/m0/merge-driver-upkeep.md deleted file mode 100644 index 75bc58c1..00000000 --- a/quest/m0/merge-driver-upkeep.md +++ /dev/null @@ -1,42 +0,0 @@ -# [S] Keep local merges and the merge driver current - -## Goal - -A local merge of the base into a quest branch needs no hand edits for -links to quests the base finished: a command, such as `quest check --fix`, -drops those list links as `quest land` does. Clones whose -`merge.quest.driver` an older quest version registered get the current -driver, while a custom driver stays untouched. The dev shell's -`quest-pinned` flake input stays current, so its merge driver has the -latest fixes. - -## Plan - -Decided 2026-11-01 (user, /quest-plan of the follow-ups from the -2026-11-01 `/quest-complete` sessions): ✅ plan it, [S], m0. - -- Finished links: - [moq-dev/quest#353](https://github.com/moq-dev/quest/pull/353) makes - `quest land` drop `Required`, `Related`, and `Closes` links to quests - the base deleted since the PR's merge base, using core `remove()`. Apply - the same edit in a checkout after merging the base, for example with - `quest check --fix` or `quest resolve --finish `, and point - the guide's merge line at it. Links in prose stay for a person to fix. -- Driver upgrades: `heal` in `src/cli/setup.ts` sets - `merge.quest.driver` only when none is configured, so clones keep the - driver an older quest wrote and miss - [moq-dev/quest#344](https://github.com/moq-dev/quest/pull/344)'s fallback - warning. Recognize earlier quest-generated driver strings and replace them - with the current `DRIVER`. -- The pin: [moq-dev/quest#344](https://github.com/moq-dev/quest/pull/344) - added the `quest-pinned` input, which only a manual `nix flake update - quest-pinned` bumps. Pick how it stays current, such as Dependabot's nix - updates, a step in the release checklist, or a recurring quest, and do it. -- Bump `quest-pinned` now (decided 2026-11-02, user, /quest-plan of the - follow-ups from that day's `/quest-complete` session): the dev shell's - `quest` still reads a leftover `quest:merge` label as a hold, which current - `quest` no longer does. In that session it reported 25-hour-old holds on - [moq-dev/quest#342](https://github.com/moq-dev/quest/pull/342), - [#338](https://github.com/moq-dev/quest/pull/338) and - [#324](https://github.com/moq-dev/quest/pull/324) that the checkout's - `quest iterate` ignored. diff --git a/src/cli/main.ts b/src/cli/main.ts index e616d616..876a2b66 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -50,7 +50,7 @@ import { EFFORT_VARIABLE, EFFORTS, fix, FixerAgentError } from "./land-fix"; import { serve, type ServeOptions } from "./land-serve"; import { watch, type WatchOptions } from "./land-watch"; import { landed } from "./landed"; -import { type Report, moveQuest, removeQuest } from "./move"; +import { type Report, fixLinks, moveQuest, removeQuest } from "./move"; import { type ResolveOptions, resolveFile } from "./resolve"; import { MODELS, ReviewInputError, processDependencies as reviewDependencies, review, roundLines } from "./review"; import { heal, init, uninstall } from "./setup"; @@ -145,14 +145,22 @@ export function main(argv: string[], out: Output): number | Promise { "is a quest's path without .md (an epic's is quest//README) and that quest exists at the merge " + "base with , the working tree must delete or edit it; a PR that finishes the quest deletes it, and " + "one that leaves work rewrites it to what remains. Any other branch adds no finding. Fetch " + - "first: a stale base can predate the quest and skip it.", + "first: a stale base can predate the quest and skip it.\n\n" + + "With --fix, first drop every Required, Related, or Closes entry that links to a quest deleted in " + + "the history of HEAD, and of MERGE_HEAD during a merge, that the working tree lacks, as quest rm " + + "does, such as the quests a merged base finished, with any heading left empty. It prints M for each " + + "edited document and leaves the edits unstaged. A quest moved rather than deleted, a link that never " + + "named a quest, and a link in prose stay for the check to report. Refuses outside a git work tree " + + "and while a quest file has unresolved conflicts.", ) .option("--base ", "Also check the quest branch's diff from its merge base with this ref") .option("--branch ", "The PR's head branch, when it differs from the current branch (needs --base)") - .action((options: { base?: string; branch?: string }) => { + .option("--fix", "First drop list links to quests the history deleted") + .action((options: { base?: string; branch?: string; fix?: boolean }) => { if (options.branch !== undefined && options.base === undefined) { throw new UsageError("--branch requires --base"); } + if (options.fix) for (const line of fixLinks(root())) print(line); const findings = check(load(root()), (path) => exists(root(), path)); if (options.base !== undefined) findings.push(...landed(root(), options.base, options.branch)); if (findings.length === 0) { @@ -1126,7 +1134,8 @@ export function main(argv: string[], out: Output): number | Promise { "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 " + + "in the clone's config when .gitattributes names merge=quest and no config sets it, or the one set is a " + + "driver an older quest registered; a custom driver stays. Without quest on " + "the merge's PATH, the driver falls back to git merge-file, and when conflicts remain it prints to stderr " + "that the file's link lists were not auto-resolved. GitHub's merges ignore merge drivers.", ) diff --git a/src/cli/move.ts b/src/cli/move.ts index 559f21ab..f7a32a94 100644 --- a/src/cli/move.ts +++ b/src/cli/move.ts @@ -1,5 +1,6 @@ // `quest mv` and `quest rm`: git moves or deletes the files and stages the // change, and the core edits the links in the documents that remain. +// `quest check --fix` makes `rm`'s link edit for quests the history deleted. import { execFileSync } from "node:child_process"; import { mkdirSync, statSync, writeFileSync } from "node:fs"; import { join, posix, relative, resolve, sep } from "node:path"; @@ -109,6 +110,47 @@ export function removeQuest(root: string, arg: string, recursive: boolean): Repo }; } +/** + * Drop every list entry that links to a quest deleted in the history of `HEAD`, and of + * `MERGE_HEAD` during a merge, that the working tree lacks, as `quest rm` drops the links to a + * quest it deletes. Merging the base brings in the quests it finished as deletions, which leaves + * this branch's links to them dangling. A quest the history moved is a rename, not a deletion, and + * a link to a path that never held a quest is a mistake; both stay for `quest check` to report, as + * does a link in prose. Writes the edited documents without staging them, and refuses while a + * quest file is unmerged, since its conflict markers are not yet a document. Returns `M path` for + * each. + */ +export function fixLinks(root: string): string[] { + workTree(root, "quest check --fix reads the deleted quests from git history"); + const unmerged = git(root, "ls-files", "--unmerged", "-z", "--", "quest") + .split("\0") + .filter((line) => line !== "") + .map((line) => line.slice(line.indexOf("\t") + 1)); + if (unmerged.length > 0) { + throw new Error(`resolve the merge conflicts in ${[...new Set(unmerged)].join(", ")} before quest check --fix`); + } + const heads = ["HEAD", "MERGE_HEAD"].filter((ref) => verified(root, ref)); + if (heads.length === 0) return []; + const deleted = new Set( + git(root, "log", "--diff-filter=D", "--find-renames", "--name-only", "--format=", "-z", ...heads, "--", "quest") + .split("\0") + .filter(isQuest), + ); + const edited = remove(sources(root), (path) => deleted.has(path) && !exists(root, path)); + for (const [path, text] of edited) writeFileSync(join(root, path), text); + return modified(edited); +} + +/** Whether `ref` names a commit, which an unborn `HEAD` or a missing `MERGE_HEAD` does not. */ +function verified(root: string, ref: string): boolean { + try { + git(root, "rev-parse", "--quiet", "--verify", `${ref}^{commit}`); + return true; + } catch { + return false; + } +} + /** * A quest path as the tree writes it (`/quest/m0/a.md`), as the shell * completes it (`quest/m0/a.md`), or as a filesystem path to it. @@ -123,12 +165,12 @@ function treePath(root: string, arg: string): string { return path; } -/** Fail outside a git work tree: these commands stage their change. */ -function workTree(root: string) { +/** Fail outside a git work tree, saying `why` the command needs one. */ +function workTree(root: string, why = "quest mv and rm stage their change with git") { try { git(root, "rev-parse", "--is-inside-work-tree"); } catch { - throw new Error(`${root} is not in a git work tree; quest mv and rm stage their change with git`); + throw new Error(`${root} is not in a git work tree; ${why}`); } } diff --git a/src/cli/resolve.ts b/src/cli/resolve.ts index 8477a1b3..21645301 100644 --- a/src/cli/resolve.ts +++ b/src/cli/resolve.ts @@ -28,6 +28,21 @@ export const DRIVER = "printf 'quest: %s, so link lists in %s were not auto-resolved\\n' \"$quest_merge_reason\" %P >&2; " + 'exit "$quest_merge_status"'; +/** + * Drivers earlier quest versions registered. A clone that kept one is upgraded to `DRIVER`, while + * any other string, a custom driver, is left alone. When `DRIVER` changes, add its old value here. + */ +export const OLD_DRIVERS: readonly string[] = [ + // moq-dev/quest#219 + "if quest help resolve >/dev/null 2>&1; then quest resolve %A %O %B; " + + "else git merge-file -L ours -L base -L theirs %A %O %B; fi", + // moq-dev/quest#256 + "if quest help resolve >/dev/null 2>&1; " + + "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; diff --git a/src/cli/setup.ts b/src/cli/setup.ts index c528fa61..28326f08 100644 --- a/src/cli/setup.ts +++ b/src/cli/setup.ts @@ -22,7 +22,7 @@ import { import { basename, dirname, join, resolve } from "node:path"; import { gitOutput } from "./claims"; -import { DRIVER } from "./resolve"; +import { DRIVER, OLD_DRIVERS } from "./resolve"; import { PREFIX, SKILLS } from "./skills"; import { HOOK_EVENTS } from "./worktree-hook"; @@ -123,13 +123,15 @@ export function heal(root: string): void { } /** - * 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. + * Set `merge.quest.driver` in the clone's config when no config sets it, or + * the one in effect is a driver an older quest registered, and only inside a + * git work tree: git ignores a driver named only in `.gitattributes`. A custom + * driver stays. A failed write is ignored, leaving the driver as it was. */ function register(root: string): void { try { - if (gitOutput(root, ["config", "--get", "merge.quest.driver"]) !== "") return; + const current = gitOutput(root, ["config", "--get", "merge.quest.driver"]); + if (current !== "" && !OLD_DRIVERS.includes(current)) return; if (gitOutput(root, ["rev-parse", "--is-inside-work-tree"]) !== "true") return; gitOutput(root, ["config", "--local", "merge.quest.driver", DRIVER]); } catch { diff --git a/test/move.test.ts b/test/move.test.ts index 88f3bc61..03499828 100644 --- a/test/move.test.ts +++ b/test/move.test.ts @@ -1,6 +1,6 @@ // `quest mv` and `quest rm`: the core edits, and the CLI's git and claim handling. -import { execFileSync } from "node:child_process"; +import { execFileSync, spawnSync } from "node:child_process"; import { existsSync } from "node:fs"; import { join } from "node:path"; @@ -10,7 +10,7 @@ import { move, remove } from "../src/core"; import { openPullRequests } from "../src/cli/claims"; import { GitHub, type GitHubDependencies } from "../src/cli/github"; import { main } from "../src/cli/main"; -import { ROOT_README, Tree, initRepository } from "./fixture"; +import { EPIC_README, ROOT_README, Tree, initRepository } from "./fixture"; // Each test spawns git up to a dozen times, which a busy Windows runner can stretch past 5 s. vi.setConfig({ testTimeout: 30_000 }); @@ -344,6 +344,102 @@ describe("quest rm", () => { }); }); +describe("quest check --fix", () => { + const THREE = + "# [S] Three\n\n## Goal\n\nMore.\n\n## Required\n\n- [One](/quest/m0/epic/one.md)\n\n" + + "## Related\n\n- [Two](/quest/m0/epic/two.md) - similar\n- [One](/quest/m0/epic/one.md) - similar\n"; + + /** + * A feature branch that adds quest three linking one and two, while main makes `change`; + * returns the tree checked out on the feature branch, before any merge. + */ + async function diverged(change: (tree: Tree) => Promise | void): Promise { + const tree = Tree.committed(); + git(tree.path, "checkout", "-q", "-b", "feature"); + tree.append("quest/m0/epic/README.md", "- [Three](/quest/m0/epic/three.md)\n").write( + "quest/m0/epic/three.md", + THREE, + ); + git(tree.path, "add", "-A"); + git(tree.path, "commit", "-q", "-m", "three"); + git(tree.path, "checkout", "-q", "main"); + await change(tree); + git(tree.path, "commit", "-q", "-am", "main"); + git(tree.path, "checkout", "-q", "feature"); + return tree; + } + + for (const commit of [true, false]) { + test(`drops list links to a quest the merged base finished${commit ? "" : ", during the merge"}`, async () => { + const tree = await diverged(async (tree) => { + expect((await quest(tree, "rm", "quest/m0/epic/one.md")).code).toBe(0); + }); + git(tree.path, "merge", "-q", "--no-ff", ...(commit ? ["-m", "merge"] : ["--no-commit"]), "main"); + expect((await quest(tree, "check")).code).toBe(1); + + const out = await quest(tree, "check", "--fix"); + expect(out).toEqual({ code: 0, stdout: "M quest/m0/epic/three.md\nquest: 5 documents ok\n", stderr: "" }); + expect(tree.read("quest/m0/epic/three.md")).toBe( + "# [S] Three\n\n## Goal\n\nMore.\n\n## Related\n\n- [Two](/quest/m0/epic/two.md) - similar\n", + ); + // The edit is left for the merge's author to stage. + expect(git(tree.path, "diff", "--name-only")).toBe("quest/m0/epic/three.md\n"); + expect(await quest(tree, "check", "--fix")).toEqual({ + code: 0, + stdout: "quest: 5 documents ok\n", + stderr: "", + }); + }); + } + + test("leaves a link to a moved quest, a mistyped link, and a prose link for the check", async () => { + const tree = await diverged((tree) => { + git(tree.path, "rm", "-q", "quest/m0/epic/one.md"); + git(tree.path, "mv", "quest/m0/epic/two.md", "quest/m0/epic/second.md"); + }); + tree.append( + "quest/m0/epic/three.md", + "- [Nope](/quest/m0/epic/nope.md)\n\n## Plan\n\nAfter [one](/quest/m0/epic/one.md).\n", + ); + git(tree.path, "commit", "-q", "-am", "more links"); + git(tree.path, "merge", "-q", "--no-ff", "-m", "merge", "main"); + + const out = await quest(tree, "check", "--fix"); + expect(out.code).toBe(1); + expect(out.stdout).toBe("M quest/m0/epic/README.md\nM quest/m0/epic/second.md\nM quest/m0/epic/three.md\n"); + expect(tree.read("quest/m0/epic/three.md")).toBe( + "# [S] Three\n\n## Goal\n\nMore.\n\n## Related\n\n- [Two](/quest/m0/epic/two.md) - similar\n" + + "- [Nope](/quest/m0/epic/nope.md)\n\n## Plan\n\nAfter [one](/quest/m0/epic/one.md).\n", + ); + for (const target of ["two.md", "nope.md", "one.md"]) { + expect(out.stderr).toContain(`link does not resolve: /quest/m0/epic/${target}`); + } + }); + + test("refuses while a quest file has unresolved conflicts", async () => { + const tree = await diverged((tree) => { + tree.write("quest/m0/epic/README.md", `${EPIC_README}- [Four](/quest/m0/epic/four.md)\n`); + }); + expect(spawnSync("git", ["-C", tree.path, "merge", "-q", "main"]).status).not.toBe(0); + const before = tree.read("quest/m0/epic/README.md"); + const out = await quest(tree, "check", "--fix"); + expect(out).toEqual({ + code: 1, + stdout: "", + stderr: "quest: resolve the merge conflicts in quest/m0/epic/README.md before quest check --fix\n", + }); + expect(tree.read("quest/m0/epic/README.md")).toBe(before); + }); + + test("fails outside a git work tree", async () => { + const out = await quest(Tree.baseline(), "check", "--fix"); + expect(out.code).toBe(1); + expect(out.stderr).toContain( + "is not in a git work tree; quest check --fix reads the deleted quests from git history", + ); + }); +}); + test("open pull requests come from every page, by head branch", async () => { const requested: string[] = []; const deps: GitHubDependencies = { diff --git a/test/resolve.test.ts b/test/resolve.test.ts index 0d1e1395..209753e8 100644 --- a/test/resolve.test.ts +++ b/test/resolve.test.ts @@ -8,7 +8,7 @@ import { fileURLToPath } from "node:url"; import { describe, expect, test } from "vitest"; import { check, chunks, parse, render, resolveLists } from "../src/core"; -import { type ResolveOptions, resolveFile } from "../src/cli/resolve"; +import { DRIVER, OLD_DRIVERS, type ResolveOptions, resolveFile } from "../src/cli/resolve"; import { heal, init, uninstall } from "../src/cli/setup"; import { tempDir } from "./fixture"; @@ -390,6 +390,42 @@ describe.skipIf(process.platform === "win32")("the merge driver", () => { expect(spawnSync("git", ["-C", dir, "config", "--get", "merge.quest.driver"]).status).toBe(1); }); + test("any command upgrades a driver an older quest registered, but not a custom one", () => { + const dir = diverged(); + writeFileSync(join(dir, ".gitattributes"), "quest/** merge=quest\n"); + expect(OLD_DRIVERS).not.toContain(DRIVER); + for (const old of OLD_DRIVERS) { + git(dir, "config", "merge.quest.driver", old); + heal(dir); + expect(git(dir, "config", "--get", "merge.quest.driver")).toBe(DRIVER); + } + // A copy of an old driver in another scope is shadowed, not edited. + const global = join(tempDir(), "gitconfig"); + writeFileSync(global, `[merge "quest"]\n\tdriver = ${JSON.stringify(OLD_DRIVERS[0])}\n`); + git(dir, "config", "--local", "--unset", "merge.quest.driver"); + const env = { ...process.env, GIT_CONFIG_GLOBAL: global }; + const read = (scope: string) => + execFileSync("git", ["-C", dir, "config", scope, "--get", "merge.quest.driver"], { + encoding: "utf8", + env, + }).trim(); + expect(read("--global")).toBe(OLD_DRIVERS[0]); + const saved = process.env.GIT_CONFIG_GLOBAL; + process.env.GIT_CONFIG_GLOBAL = global; + try { + heal(dir); + } finally { + if (saved === undefined) delete process.env.GIT_CONFIG_GLOBAL; + else process.env.GIT_CONFIG_GLOBAL = saved; + } + expect(read("--local")).toBe(DRIVER); + expect(read("--global")).toBe(OLD_DRIVERS[0]); + + git(dir, "config", "merge.quest.driver", `${OLD_DRIVERS[0]} # mine`); + heal(dir); + expect(git(dir, "config", "--get", "merge.quest.driver")).toBe(`${OLD_DRIVERS[0]} # mine`); + }); + test("nothing registers without attributes or outside a work tree", () => { const dir = diverged(); heal(dir); From afbf1f9ecfa1b263ac05663cac518968fe3ea449 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 2 Nov 2026 20:00:04 -0800 Subject: [PATCH 3/3] fix(check): read --fix deletions relative to a nested quest root Co-Authored-By: Claude Opus 5.5 --- src/cli/move.ts | 15 ++++++++++++++- test/move.test.ts | 16 +++++++++++++++- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/cli/move.ts b/src/cli/move.ts index f7a32a94..5e5dae61 100644 --- a/src/cli/move.ts +++ b/src/cli/move.ts @@ -132,7 +132,20 @@ export function fixLinks(root: string): string[] { const heads = ["HEAD", "MERGE_HEAD"].filter((ref) => verified(root, ref)); if (heads.length === 0) return []; const deleted = new Set( - git(root, "log", "--diff-filter=D", "--find-renames", "--name-only", "--format=", "-z", ...heads, "--", "quest") + // `--relative` names paths from `root`, as the tree's links do, when the tree is nested in its repository. + git( + root, + "log", + "--relative", + "--diff-filter=D", + "--find-renames", + "--name-only", + "--format=", + "-z", + ...heads, + "--", + "quest", + ) .split("\0") .filter(isQuest), ); diff --git a/test/move.test.ts b/test/move.test.ts index 03499828..d582b070 100644 --- a/test/move.test.ts +++ b/test/move.test.ts @@ -10,7 +10,7 @@ import { move, remove } from "../src/core"; import { openPullRequests } from "../src/cli/claims"; import { GitHub, type GitHubDependencies } from "../src/cli/github"; import { main } from "../src/cli/main"; -import { EPIC_README, ROOT_README, Tree, initRepository } from "./fixture"; +import { EPIC_README, ROOT_README, Tree, initRepository, tempDir } from "./fixture"; // Each test spawns git up to a dozen times, which a busy Windows runner can stretch past 5 s. vi.setConfig({ testTimeout: 30_000 }); @@ -416,6 +416,20 @@ describe("quest check --fix", () => { } }); + test("reads deletions relative to a tree nested in its repository", async () => { + const repo = tempDir(); + const tree = new Tree(join(repo, "nested")); + Tree.baseline().copy(tree.path); + committed(new Tree(repo)); + git(repo, "rm", "-q", "nested/quest/m0/epic/one.md"); + git(repo, "commit", "-q", "-m", "finish one"); + expect(await quest(tree, "check", "--fix")).toEqual({ + code: 0, + stdout: "M quest/m0/epic/README.md\nM quest/m0/epic/two.md\nquest: 4 documents ok\n", + stderr: "", + }); + }); + test("refuses while a quest file has unresolved conflicts", async () => { const tree = await diverged((tree) => { tree.write("quest/m0/epic/README.md", `${EPIC_README}- [Four](/quest/m0/epic/four.md)\n`);