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 eb51fd50..e1c2453d 100644 --- a/README.md +++ b/README.md @@ -310,9 +310,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 c2e02579..bbac5d6f 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 6e0a6311..a3177d18 100644 --- a/quest/m0/README.md +++ b/quest/m0/README.md @@ -96,7 +96,6 @@ ownership-manifest design: - [Gate bench](/quest/m0/gate-bench.md) - measure headroom admission on an idle host - [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` -- [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 a9656509..07c151eb 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"; @@ -148,14 +148,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 tree = checkout(root(), warn); const findings = check(tree.docs, tree.exists); if (options.base !== undefined) findings.push(...landed(root(), options.base, options.branch)); @@ -1131,7 +1139,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 f6d56b02..ae02b0f2 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,60 @@ 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( + // `--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), + ); + 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 +178,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 568c744a..95c8ad16 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"; @@ -127,13 +127,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..d582b070 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, 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 }); @@ -344,6 +344,116 @@ 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("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`); + }); + 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);