gitea: twelve characters name a head, and what landed is read from the merge commit (review of issue 381)
mesh/delivery superseded: a newer head of the same pull request
mesh/merge-gate pass: builds gitea → novox; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)

The review of #193 approved it with five nits, all taken.

Seven characters of a sha can be matched by a commit made to match them
in seconds, so an author who force-pushes could have a review merge a
commit nobody read. A head is now named by at least 12 characters, up to
64 for a sha-256 repository.

What landed was read from the forge's record of the pull request, which
is the very thing a race leaves behind. It is now read from the merge
commit: its second parent for a merge, the commit itself for a
fast-forward. A squash or a rebase makes new commits that name no head,
and the answer says it cannot be read back instead of staying silent.

A merge of another head than the one handed over was reported as a
field beside merged: true, which a caller reading only that would miss.
It is now an error that says the merge happened and what landed.

The refusal no longer asserts that the head moved after the review,
which it cannot know; it says the head is not the one named.
This commit is contained in:
2026-10-11 01:33:17 +02:00
parent c32eb6237d
commit 9ef810d9c3
3 changed files with 86 additions and 31 deletions
+33 -10
View File
@@ -18,6 +18,9 @@ import {
ruleCovers,
} from "../guard.js";
/** A head named for a merge: at least 12 hex characters of its sha (novox/hq issue 381), up to a sha-256 commit's 64. */
const REVIEWED_HEAD = /^[0-9a-f]{12,64}$/;
/** The forge's container, where its repositories and git are: the note is written there (novox/hq ADR 0239). */
const forgeContainer = process.env.MESH_GITEA_CONTAINER || "gitea";
@@ -270,7 +273,7 @@ function allGiteaTools(gitea: GiteaClient): ToolDefinition[] {
owner: { type: "string", description: "the repository owner" },
repo: { type: "string", description: "the repository name" },
number: { type: "number", description: "the PR number" },
head: { type: "string", description: "the commit you reviewed, as its sha or at least its first 7 characters: " +
head: { type: "string", description: "the commit you reviewed, as its sha or at least its first 12 characters: " +
"refused unless it is the pull request's head" },
method: { type: "string", description: "merge | rebase | squash (default merge)" },
delete_branch: { type: "boolean", description: "delete the source branch after merge (default true)" },
@@ -295,16 +298,18 @@ function allGiteaTools(gitea: GiteaClient): ToolDefinition[] {
throw new Refused(`${owner}/${repo}#${number} is not merged: name the head you reviewed (head), so that only ` +
`that commit can land (novox/hq issue 381). Nothing was merged`);
}
if (!isCommit(named)) {
throw new Refused(`${owner}/${repo}#${number} is not merged: head ${named} is not a commit — a sha, or at least ` +
`its first 7 characters. Nothing was merged`);
// Twelve characters, not seven: an author who force-pushes can make a commit whose first seven match the
// reviewed one in seconds, and the review would then merge it. Twelve cannot be matched that way.
if (!REVIEWED_HEAD.test(named)) {
throw new Refused(`${owner}/${repo}#${number} is not merged: head ${named} does not name one commit — give its ` +
`sha, or at least its first 12 characters. Nothing was merged`);
}
const pull = await gitea.getPullRequest(owner, repo, number);
if (pull.merged) throw new Refused(`${owner}/${repo}#${number} is already merged`);
if (!pull.head_sha || !pull.base) throw new Refused(`${owner}/${repo}#${number} names no head commit or base branch`);
if (!pull.head_sha.toLowerCase().startsWith(named)) {
throw new Refused(`${owner}/${repo}#${number} is not merged: its head is ${pull.head_sha.slice(0, 12)}, not the ` +
`${named} you named — it moved after that was reviewed. Review the head it has now, then name it. Nothing was merged`);
`${named} you named. If you reviewed an earlier head, review the one it has now, then name it. Nothing was merged`);
}
const rule = await gitea.branchProtection(owner, repo, pull.base);
// Only into a branch whose protection requires the checks: a pull request whose base was moved to a
@@ -325,12 +330,30 @@ function allGiteaTools(gitea: GiteaClient): ToolDefinition[] {
const merged = await gitea.getPullRequest(owner, repo, number);
const answer: Record<string, unknown> = { merged: true, number, method, head: pull.head_sha,
deleted_branch: deleteBranch, merge_commit_sha: merged.merge_commit_sha };
// What landed, read back: the forge was handed the head, and this says so if it merged another one anyway.
if (merged.head_sha && merged.head_sha !== pull.head_sha) {
answer.landed = merged.head_sha;
answer.not_the_head_named = `the forge merged ${merged.head_sha.slice(0, 12)}, not the ${pull.head_sha.slice(0, 12)} ` +
`it was handed: a commit nobody reviewed may have landed. Read it before anything is built from it (novox/hq issue 381)`;
// What landed, read back from the commit the merge made rather than from the forge's record of the pull
// request, which is the very thing a race would leave behind. A merge commit's second parent is the head it
// joined; a fast-forward lands the head itself. A squash or a rebase makes new commits that name no head,
// and the answer says it cannot be read back rather than staying silent.
let landed: string | undefined;
if (!merged.merge_commit_sha) {
answer.read_back = "the forge named no merge commit, so what landed cannot be read back";
} else if (method === "merge") {
const parents = await gitea.commitParents(owner, repo, merged.merge_commit_sha);
if (parents.length === 2) landed = parents[1];
else answer.read_back = `the merge commit has ${parents.length} parent(s), not two: what landed cannot be read back`;
} else if (method === "fast-forward-only") {
landed = merged.merge_commit_sha;
} else {
answer.read_back = `a ${method} makes new commits that name no head: what landed cannot be read back`;
}
// Loud, not a field beside merged: true that a caller reading only that would miss. The merge has happened and
// cannot be taken back by this tool; what must not happen is that it reads as the merge that was asked for.
if (landed !== undefined && landed !== pull.head_sha) {
throw new Error(`${owner}/${repo}#${number} WAS merged (${merged.merge_commit_sha}), but the forge merged ` +
`${landed.slice(0, 12)}, not the ${pull.head_sha.slice(0, 12)} it was handed and you reviewed: a commit nobody ` +
`reviewed has landed. Read it before anything is built from it (novox/hq issue 381)`);
}
if (landed !== undefined) answer.landed = landed;
return answer;
},
},