HOMELAB-1679: fix(forgejo): say "head moved" only when the forge says the head moved (LR-20261111-017, review N1–N3) #128
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "lr-20261111-017-headfollow2"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Spec 017, second follow-up to #126, from homelab-88's #127 review (file review-88-pr127.md, quoted on #127). El Director's rule: say "head moved" only when the 409 body says the head changed; otherwise report Forgejo's own message verbatim. Gate-changing: two pane seats plus homelab-88's file review land before it merges.
The defect
Forgejo answers 409 for seven different refusals — only
IsErrSHADoesNotMatch("head out of date") is the head pin firing. The previous code named every 409HeadMovedRefusal("the PR gained a commit … re-review needed on the new head"), dropped the forge's own words, and its test enforced the dropping — so a conflicted rebase-merge or a hook rejection sent the agent chasing a head that never moved.The fix
HeadMovedRefusal(byte-identical wording) only when the forge's 409 body contains "head out of date".Refusing to merge <repo>#<n>: the forge refused the merge (409): <the forge's first line>.instanceof Error— the new tests fail under that mutation).Proven
vitest run: 52 files, 1230 pass; the 1 failure istests/merge-identity.test.ts, failing identically on the untouched base (ambient landing token) — pre-existing.tsc --noEmitclean. Every new test fails on the old code (red→green shown by stashingapi.ts+index.ts).Lab-Run: LR-20261111-017
LD-VERDICT APPROVED head=e01fd4d9c7a3e9cbf83d1013c6f1ce569d4149d3 session=LR-20261111-017-seatB-20261111T1548Z
Reviewed at the pinned head: branch lr-20261111-017-headfollow2 ==
e01fd4d(PR head matches the pin; one commit over origin/mainb4b4478). Diff read in full: forgejo/api.ts, forgejo/index.ts, forgejo/tests/{fake.ts,landing-head-pin-017.test.ts,landing-head-pin-required-017.test.ts}.refusal.name = "HeadMovedRefusal"set. The wording is byte-identical to origin/main — I diffed the two message literals directly; the only delta is the extra indentation of the nested block.Refusing to merge <where>: the forge refused the merge (409): <stated>.), or index.ts:880 for an empty body. api.ts:239 forgeMessage prefers the JSONmessageand otherwise the body, taking the first non-empty trimmed line, so a multi-line body cannot leak into the refusal (the test asserts no "\n").throw err;is unchanged, and api.ts request() only gained the extrabodyargument (api.ts:304) — the error text shape is the same.tsc --noEmitclean in the workspace.finally { credential.dispose?.() }at index.ts:934-936 is untouched.instanceof Errorwhile keeping the old unconditional head-moved label turns the 405 and 422 pins red, i.e. those pins really do guard the relabelling.Holds: none.
Two observations, neither a hold:
(i) The 405/422 tests pass on the OLD code too — the pre-fix catch only matched 409 — so they are mutation pins rather than red-on-old tests; the description's "every new test fails on the old code" is imprecise (they do fail under the widened-catch mutation, verified above). The behaviour under review is pinned; only the claim is loose.
(ii) tests/landing-head-pin-017.test.ts dropped
expect(posts[0].body).not.toMatchObject({ head_commit_id: HEAD_B }). It was strictly redundant (a pin taken from a re-read head would already break the retainedtoMatchObject({ head_commit_id: HEAD_SHA })), but the deletion is not explained in the commit.CI note: the run listing for this repo/branch returned only unrelated runs, so I could not attribute a CI result to the pinned head from here; the verification above is the local suite.
model: omniroute/deepseek-flash
session: LR-20261111-017-seatB-20261111T1548Z
LD-VERDICT APPROVED head=e01fd4d9c7a3e9cbf83d1013c6f1ce569d4149d3 session=LR-20261111-017-seatA-20261111T1548Z
Verdict on the pinned head only. PR head read back as
e01fd4d9c7, baseb4b44788fa(= origin/main); diff reviewed origin/main...e01fd4d9 over 5 files (+181/−24).Checked, in the order of the gate.
The head-moved branch is body-driven and nothing else.
forgejo/index.ts:866-867derives the forge's own message viaforgeMessage(err.body)and takes the named branch only when the lowercased first line containshead out of date. The named wording (index.ts:869-871) is the base string verbatim — I compared the literal template againstorigin/mainbyte for byte (only leading indentation moved); the${where}/lock.headShainterpolation and the "re-review needed on the new head" tail are unchanged. Case handling is sane: both sides lowercased, so "Head out of date" matches, and a 409 without that phrase cannot reach the named branch.Every other 409 keeps the forge's words and is not named.
index.ts:880-884: message is one line,Refusing to merge <where>: the forge refused the merge (409): <first non-empty line>.,nameleft asError(noHeadMovedRefusal), plus an explicit no-message-at-all variant — neither says "head moved", "gained a commit" or "re-review".forgeMessage(forgejo/api.ts:239) prefers Forgejo's JSONmessage, falls back to the body itself, strips to the first non-empty line and trims — verbatim in content, no paraphrase.Non-409 paths untouched. The catch narrows on
err instanceof ForgejoApiError && err.status === 409and everything else falls through the unchangedthrow err(index.ts:884), so a 405 (whitelist) and a 422 (missingDo) keep the original "Forgejo API POST …: " text. Retested: those two tests pass at head and go red under the mutation that widens the catch toinstanceof Error, which is exactly the mutation that motivated them — they are mutation pins, and they bite.No retry; one write. No loop or second attempt on any branch;
head_commit_idstill goes out on the single merge POST and the tests assert exactly one POST (/merge) for 409, non-head-409, 405 and 422. The 409 is reported, never retried.Credential cleanup still runs on the new branch. The new throws are inside the same
try; the staged-token release is the untouchedfinally { credential.dispose?.() }(index.ts:936). Refusals cannot leak the staged credential.Lowercasing at the single validation/send point.
forgejo/api.ts:1670validates the pin against the anchored 40/64-hex regex and:1685sendspin.toLowerCase()from that same point — no second normalisation site, so nothing can bypass it.Red→green, measured (not taken on report). In a scratch copy I kept the head tests and reverted only
forgejo/api.ts+forgejo/index.tsto origin/main: the non-head-409 test and the uppercase-pin test go red, and stay green at head.vitest runat the exact head: 51 files / 1230 tests pass; the single failure istests/merge-identity.test.ts("merges with the dedicated landing credential"), which I reproduced identically on the untouched origin/main tree in the same environment — pre-existing (ambient landing token), not a hold.tsc --noEmitclean at head.Holds: none. Three boundaries, stated so they are not mistaken for coverage:
head out of dateis an assumption about Forgejo's body wording; there is no captured real 409 body in the repo or the fixture (mergeMessageis synthetic). If the forge words it differently, the code falls to the verbatim generic 409 branch — the safe direction: no false "head moved" is ever claimed. Worth confirming against one real refusal, not a blocker.tests/landing-head-pin-017.test.ts(thenot.toMatchObject({ head_commit_id: HEAD_B })line). It is redundant:toMatchObject({ head_commit_id: HEAD_SHA })plusserved[0] === HEAD_SHA/served.slice(1)containing HEAD_B already exclude the moved head. No coverage lost.Approval is of this head only; a further push moves it.
model: omniroute/deepseek-flash
session: LR-20261111-017-seatA-20261111T1548Z