Skip to content

Squash-Merge Defeats Branch-Name PR Lookup — Why "No PR By This Name" Isn't Evidence of Abandonment

Applies to: Anyone writing a merge-status check for a worker branch in this repo — an autopilot runbook, a CI guard, or a one-off gh query — that infers "landed" or "abandoned" from a branch's name or its ancestry count against master.

The trap

Two structural facts about this repo's workflow combine to make a branch-name-based merge check reach the wrong answer, permanently, for every worker branch that ever ships cleanly:

  1. A worker branch's name never appears as a PR's headRefName. Codi, Tess, Desi, Dave, and Dori commit to agent/<name>/<hash>, but Riley opens the PR from Riley's own branch — not from the worker's. A lookup that resolves "has a merged PR" by matching the candidate branch's name against gh pr list's headRefName therefore never finds a match for a worker branch, no matter how cleanly its work shipped. "No PR by this branch name" is the normal, healthy end state for a worker branch, not a signal of anything.
  2. git rev-list origin/master..<tip> never reaches zero after a squash-merge. Squashing rewrites a branch's commits into one brand-new SHA on master; the branch's original commits are never ancestors of that new SHA, no matter how long ago they landed. An "ahead of master" count built on this ancestry walk stays positive forever for a squash-merged branch — it asserts nothing about landedness, only about ancestry, and this repo squashes every phase/worker PR.

Neither fact is a bug in Git or GitHub — they're both working as designed. The bug is treating branch-name matching or ancestry counting as a merge signal in a repo that uses this workflow.

What actually happened

The weekly Riley — Stale branch pruning autopilot (dcfaa30e-5c6d-4a24-9208-48db2a160d2d) used exactly these two signals in its Step 5 no-PR/no-merge check. Its 2026-08-31 run flagged 16 branches as "no open PR and no merged PR ever," tip commits ≥90 days old, and recommended "investigate — real unmerged work, never PR'd" for 15 of them, filed as OBJ-3088.

Full human triage on OBJ-3088 found all 16 were already fully landed on master. Zero real findings, 16 false positives, one triage pass burned for nothing — and because the two structural facts above don't decay, every branch that ages past the threshold would keep presenting as unmerged work, forever, in a list that only grows.

A later replay of the same 16 tip SHAs against the corrected per-commit lookup (below) produced the honest split: landed 1, content-landed 0, unlanded 15 — most of the 16 genuinely can't be proven landed by either signal, which is a different, weaker, and more honest claim than "these are real unmerged work." See OBJ-3099 for the full replay.

The fix — resolve per commit, not per branch name

scripts/multica-stale-branch-triage.mjs (OBJ-3099) replaces both broken signals with two independent, fail-closed checks that never consult a branch name:

  1. Per-commit PR lookupGET /repos/{owner}/{repo}/commits/{sha}/pulls. This resolves through a squash, because GitHub keeps the association from the original commit to the PR that merged it, independent of ancestry. Every unique commit on the branch (not reachable from master) must resolve to at least one PR with a non-null merged_at for the branch to count as landed.
  2. Content equivalence — every file the branch touched (relative to its merge-base with master) is byte-identical on master today. This is the fallback for a commit with no PR match at all: if the diff is already present on master, the work landed some other way (e.g. cherry-picked, re-authored) even though no PR record ties it back.

Neither signal met ⇒ unlandedunproven, never "abandoned." Any API error, malformed response, page-cap hit, or over-cap branch ⇒ unknown, never deletable. landed/content-landed are the only deletable verdicts, and both carry evidence (SHA → PR number + merged_at, or path → byte-identity result) a third party can re-derive without re-running the script. Full mode reference: scripts/README.md#multica-stale-branch-triagemjs.

Two traps in the fix itself

Both bit the fix's own first pass:

  • The field is merged_at (REST, snake_case), never mergedAt (GraphQL). commits/<sha>/pulls is a REST endpoint; gh pr view --json mergedAt is a different, GraphQL-only field. Using the camelCase form silently reads as "never merged" for a demonstrably-merged PR — reproducing exactly the false conclusion this gotcha exists to prevent.
  • Never gate on .state. The REST endpoint reports "closed" for a merged PR too, not just an abandoned one. Gate only on merged_at being non-null.

Known gaps (documented, not fixed)

  • commits/<sha>/pulls is unpaginated, capped at 30 items. A response landing exactly at that cap resolves to unknown (fail-closed), not silently truncated to the first page — see classifyPrLookupResponse in the script.
  • A plausible third signal is unimplemented: grepping master's history for the branch's commit-message subject lines (GitHub's squash-merge commit body retains them, and they survive SHA rewriting). Unvalidated, deliberately out of scope for OBJ-3099.
  • The 90-day default staleness window is a long time for both signals to fight master drift. A longer window gives content on master more time to diverge from what the branch touched, lowering the proof rate for content-landed. Lowering --stale-days would likely raise the proof rate; changing the default is a separate, evidence-backed call, not bundled into this fix. The value is exported as DEFAULT_STALE_DAYS specifically so this is a one-line change when someone makes that call.

Where else this shows up

This is the same root workflow fact — a worker branch commits under its own name but ships under Riley's PR — surfacing in a second, different symptom: a Riley-opened PR's own body or metadata doesn't carry a back-reference to the worker's branch name either, since the PR is Riley's branch. Anywhere a check or a habit assumes "the PR that shipped this work will name the worker's branch somewhere" is vulnerable to the same root cause as this doc's branch-name-lookup failure, just triggered from the PR side instead of the branch side.

Current status

As of this writing, archive/* is a protected prefix (a standing Riley pruning-sweep policy — deliberate, permanent retirement records, not stale branches) and every one of the repo's current stale, unprotected candidates is archive/agent-*-superseded-by-*. A live dry run of the fixed script at the default 90-day threshold therefore reports 0 stale, unprotected candidates — the sweep deletes nothing today. That is the correct, honest result of the fix, not a bug to chase; it will start reporting real candidates again once a worker branch old enough to be stale is left behind uncleaned outside archive/.

The Riley — Stale branch pruning autopilot (dcfaa30e-5c6d-4a24-9208-48db2a160d2d) does not yet call this script — Riley's interim fix inlined an equivalent per-commit check directly into the autopilot runbook prose (weaker: no archive/* handling, none of the four destructive-path defect fixes below) to cover the 2026-09-07 scheduled run while OBJ-3099 was in flight. Pointing the autopilot at the script directly is tracked separately as OBJ-3110, blocked on OBJ-3099 merging to master — see docs/guides/multica-autopilots.md § 5b.

  • Four additional defects were found and fixed in the script's destructive (delete) path during the same OBJ-3099 pass — pathspec fail-open via C-quoting, pathspec fail-open via magic-character interpretation, argument injection via a leading-dash branch name, and stale remote-tracking refs. These are independent of the merge-check trap this doc covers; see the header comment in scripts/multica-stale-branch-triage.mjs for the full mechanics of each.
  • OBJ-3088 — the 16-false-positive triage run that surfaced this.
  • OBJ-3099 — root cause, the corrected script, and the replay showing the honest 1/0/15 split.
  • OBJ-3110 — pending post-merge follow-up to point the autopilot at the script directly.

Version: v4.7.0 Last updated: 2026-09-01

Loading…