Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
14 changes: 11 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions assets/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions flake.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 3 additions & 2 deletions flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
1 change: 0 additions & 1 deletion quest/m0/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
42 changes: 0 additions & 42 deletions quest/m0/merge-driver-upkeep.md

This file was deleted.

17 changes: 13 additions & 4 deletions src/cli/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -148,14 +148,22 @@ export function main(argv: string[], out: Output): number | Promise<number> {
"is a quest's path without .md (an epic's is quest/<path>/README) and that quest exists at the merge " +
"base with <ref>, 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 <ref> " +
"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 <ref>", "Also check the quest branch's diff from its merge base with this ref")
.option("--branch <name>", "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));
Expand Down Expand Up @@ -1131,7 +1139,8 @@ export function main(argv: string[], out: Output): number | Promise<number> {
"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.",
)
Expand Down
61 changes: 58 additions & 3 deletions src/cli/move.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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.
Expand All @@ -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}`);
}
}

Expand Down
15 changes: 15 additions & 0 deletions src/cli/resolve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
12 changes: 7 additions & 5 deletions src/cli/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading