Repository navigation
feat(check): guard that a quest's PR leaves its quest accurate #169
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
83beb4c
9e77cb3
504a9f1
cf27b05
5372292
cce32bd
a3ec5b2
1cafbdc
f685884
194a153
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
This file was deleted.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,61 @@ | ||
| // Whether a PR from a quest's branch leaves that quest accurate: it deletes | ||
| // the finished quest or rewrites it to what remains, so either way the diff | ||
| // from the base touches the quest's file. Reading the diff needs git, so it | ||
| // lives here rather than in the core. | ||
|
|
||
| import { spawnSync } from "node:child_process"; | ||
|
|
||
| import { type Finding } from "../core"; | ||
|
|
||
| /** | ||
| * A finding when `branch` (default: the current branch) is a quest's branch | ||
| * and the working tree neither deletes nor modifies that quest relative to its | ||
| * merge base with `base`. Not a quest branch, or no such quest at the merge | ||
| * base, is no finding. | ||
| */ | ||
| export function landed(root: string, base: string, branch?: string): Finding[] { | ||
| const name = branch ?? currentBranch(root); | ||
| const mergeBase = git(root, ["merge-base", base, "HEAD"]); | ||
| if (mergeBase.status !== 0) { | ||
| throw new Error(`no merge base between ${base} and HEAD: ${mergeBase.stderr.trim() || "unrelated histories"}`); | ||
| } | ||
| const commit = mergeBase.stdout.trim(); | ||
| const path = questAt(root, commit, name); | ||
| if (path === null) return []; | ||
| // The pathspec and `<rev>:./path` are relative to the root, which may sit | ||
| // below the repository's top level. | ||
| const diff = git(root, ["diff", "--quiet", "--no-ext-diff", commit, "--", `./${path}`]); | ||
| if (diff.status === 1) return []; | ||
|
Comment on lines
+27
to
+28
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
On Unix repositories with Useful? React with 👍 / 👎. |
||
| if (diff.status !== 0) throw new Error(`git diff ${commit} -- ${path}: ${diff.stderr.trim()}`); | ||
| return [ | ||
| { | ||
| path, | ||
| line: null, | ||
| message: | ||
| `branch ${name} neither deletes nor edits its quest since ${base}; a PR that finishes a quest deletes ` + | ||
| "it and every link to it, and one that leaves work rewrites it to what remains (`quest guide`, Execution)", | ||
| }, | ||
| ]; | ||
| } | ||
|
|
||
| /** | ||
| * The quest file `branch` names, if it existed at `commit`: the branch plus | ||
| * `.md`, so an epic's branch ends in `/README`. | ||
| */ | ||
| function questAt(root: string, commit: string, branch: string): string | null { | ||
| if (!branch.startsWith("quest/") || branch.endsWith("/")) return null; | ||
| const path = `${branch}.md`; | ||
| return git(root, ["cat-file", "-e", `${commit}:./${path}`]).status === 0 ? path : null; | ||
| } | ||
|
|
||
| function currentBranch(root: string): string { | ||
| const head = git(root, ["symbolic-ref", "--quiet", "--short", "HEAD"]); | ||
| if (head.status !== 0) throw new Error("HEAD is not on a branch; pass --branch with the PR's head branch"); | ||
| return head.stdout.trim(); | ||
| } | ||
|
|
||
| function git(root: string, args: string[]): { status: number | null; stdout: string; stderr: string } { | ||
| const result = spawnSync("git", ["-C", root, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }); | ||
| if (result.error) throw new Error(`running git: ${result.error.message}`); | ||
| return result; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,142 @@ | ||
| // `quest check --base`: a PR from a quest's branch deletes or edits its quest. | ||
|
|
||
| import { execFileSync } from "node:child_process"; | ||
|
|
||
| import { expect, test } from "vitest"; | ||
|
|
||
| import { Tree } from "./fixture"; | ||
|
|
||
| const FINDING = "neither deletes nor edits its quest since main"; | ||
|
|
||
| /** The baseline tree committed on `main`, then checked out on `branch`. */ | ||
| function repo(branch: string): Tree { | ||
| const tree = Tree.baseline(); | ||
| git(tree, "init", "--quiet", "--initial-branch=main"); | ||
| commit(tree, "baseline"); | ||
| git(tree, "switch", "--quiet", "-c", branch); | ||
| return tree; | ||
| } | ||
|
|
||
| function git(tree: Tree, ...args: string[]): string { | ||
| return execFileSync("git", ["-C", tree.path, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }); | ||
| } | ||
|
|
||
| function commit(tree: Tree, message: string) { | ||
| git(tree, "add", "--all"); | ||
| git( | ||
| tree, | ||
| "-c", | ||
| "user.name=Quest", | ||
| "-c", | ||
| "user.email=quest@example.com", | ||
| "-c", | ||
| "commit.gpgsign=false", | ||
| "commit", | ||
| "--quiet", | ||
| "--allow-empty", | ||
| "-m", | ||
| message, | ||
| ); | ||
| } | ||
|
|
||
| test("a quest branch that deletes its quest passes", () => { | ||
| const tree = repo("quest/m0/epic/one"); | ||
| tree.remove("quest/m0/epic/one.md") | ||
| .write( | ||
| "quest/m0/epic/README.md", | ||
| "# Epic\n\n## Goal\n\nAn epic.\n\n## Required\n\n- [Two](/quest/m0/epic/two.md)\n", | ||
| ) | ||
| .write("quest/m0/epic/two.md", "# [S] Two\n\n## Goal\n\nAnother quest.\n"); | ||
| commit(tree, "finish one"); | ||
| expect(tree.run("check", "--base", "main")).toEqual({ code: 0, stdout: "quest: 4 documents ok\n", stderr: "" }); | ||
| }); | ||
|
|
||
| test("a quest branch that edits its quest passes", () => { | ||
| const tree = repo("quest/m0/epic/one"); | ||
| tree.append("quest/m0/epic/one.md", "\n## Plan\n\nWhat remains.\n"); | ||
| commit(tree, "trim one"); | ||
| expect(tree.run("check", "--base", "main").code).toBe(0); | ||
| }); | ||
|
|
||
| test("a quest branch that leaves its quest untouched is a finding", () => { | ||
| const tree = repo("quest/m0/epic/one"); | ||
| tree.write("src.ts", "work\n"); | ||
| commit(tree, "work"); | ||
| const out = tree.run("check", "--base", "main"); | ||
| expect(out.code).toBe(1); | ||
| expect(out.stderr).toBe( | ||
| `quest: quest/m0/epic/one.md: branch quest/m0/epic/one ${FINDING}; a PR that finishes a quest deletes it and ` + | ||
| "every link to it, and one that leaves work rewrites it to what remains (`quest guide`, Execution)\n", | ||
| ); | ||
| // A plain check is unchanged. | ||
| expect(tree.run("check").code).toBe(0); | ||
| }); | ||
|
|
||
| test("the working tree counts, before it is committed", () => { | ||
| const tree = repo("quest/m0/epic/one"); | ||
| expect(tree.run("check", "--base", "main").stderr).toContain(FINDING); | ||
| tree.append("quest/m0/epic/one.md", "\nMore.\n"); | ||
| expect(tree.run("check", "--base", "main").code).toBe(0); | ||
| }); | ||
|
|
||
| test("an epic's branch, its README's path without .md, is checked against its README", () => { | ||
| const tree = repo("quest/m0/epic/README"); | ||
| expect(tree.run("check", "--base", "main").stderr).toContain( | ||
| `quest/m0/epic/README.md: branch quest/m0/epic/README ${FINDING}`, | ||
| ); | ||
| tree.append("quest/m0/epic/README.md", "\nMore.\n"); | ||
| expect(tree.run("check", "--base", "main").code).toBe(0); | ||
| }); | ||
|
|
||
| test("an epic's directory is not its branch", () => { | ||
| expect(repo("quest/m0/epic").run("check", "--base", "main").code).toBe(0); | ||
| }); | ||
|
|
||
| test("a branch that is not a quest branch adds no finding", () => { | ||
| const tree = repo("fix/typo"); | ||
| expect(tree.run("check", "--base", "main").code).toBe(0); | ||
| }); | ||
|
|
||
| test("a branch naming no quest at the merge base adds no finding", () => { | ||
| const tree = repo("quest/m0/epic/three"); | ||
| tree.write("quest/m0/epic/three.md", "# [S] Three\n\n## Goal\n\nNew.\n"); | ||
| tree.append("quest/m0/epic/README.md", "- [Three](/quest/m0/epic/three.md)\n"); | ||
| commit(tree, "plan three"); | ||
| expect(tree.run("check", "--base", "main").code).toBe(0); | ||
| }); | ||
|
|
||
| test("--branch overrides the current branch", () => { | ||
| const tree = repo("local-name"); | ||
| expect(tree.run("check", "--base", "main").code).toBe(0); | ||
| expect(tree.run("check", "--base", "main", "--branch", "quest/m0/epic/two").stderr).toContain( | ||
| `quest/m0/epic/two.md: branch quest/m0/epic/two ${FINDING}`, | ||
| ); | ||
| }); | ||
|
|
||
| test("the base is compared from its merge base, not its tip", () => { | ||
| const tree = repo("quest/m0/epic/two"); | ||
| // main moves on and edits the quest; the branch itself never touched it. | ||
| git(tree, "switch", "--quiet", "main"); | ||
| tree.append("quest/m0/epic/two.md", "\nUpstream.\n"); | ||
| commit(tree, "upstream"); | ||
| git(tree, "switch", "--quiet", "quest/m0/epic/two"); | ||
| expect(tree.run("check", "--base", "main").stderr).toContain(FINDING); | ||
| }); | ||
|
|
||
| test("a detached HEAD needs --branch", () => { | ||
| const tree = repo("quest/m0/epic/one"); | ||
| git(tree, "switch", "--quiet", "--detach"); | ||
| const out = tree.run("check", "--base", "main"); | ||
| expect(out.code).toBe(1); | ||
| expect(out.stderr).toContain("HEAD is not on a branch; pass --branch"); | ||
| }); | ||
|
|
||
| test("an unknown base fails", () => { | ||
| const out = repo("quest/m0/epic/one").run("check", "--base", "nope"); | ||
| expect(out.code).toBe(1); | ||
| expect(out.stderr).toContain("no merge base between nope and HEAD"); | ||
| }); | ||
|
|
||
| test("--branch without --base is a usage error", () => { | ||
| expect(Tree.baseline().run("check", "--branch", "quest/m0/epic/one").code).toBe(2); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When the PR head is already checked out with an uncommitted edit or deletion of its quest,
landed.tscompares the merge base against that working tree, so this check passes even though the fetched PR head does not contain the change. Because this sequence pushes only after a finding, it can then enable auto-merge without publishing the quest update;git checkout -hconfirms that only a forced checkout throws away local modifications. Require a clean checkout or validate the committed fetched head before trusting the result.AGENTS.md reference: assets/AGENTS.md:L129-L132
Useful? React with 👍 / 👎.