fix([issue-134]): assert review-fix commits are pushed before opening a PR or merging - #137
Merged
Conversation
… a PR or merging Every reviewer loop pushes its own fix commits as its final step, but nothing downstream verified it happened. An orchestrator that improvised a loop body instead of following it left the fixes local: gh pr create opened a PR carrying only the pre-review commit, every reviewer still reported clean, CI still passed (it was testing the pushed tree, green because it predated the fixes), and the merge gate had nothing to object to. The failure was completely silent. Primary fix in the multi-reviewer wrapper, so it lands once for all seven commands that drive it: series dispatch gains a step 5 asserting the pass's commits reached the remote, pushing with the loops' own one-retry, and recording a new push-failed status when the push still fails. Parallel dispatch gets the same assertion after its single union apply. push-failed is inconclusive and, unlike the other inconclusive statuses, is never excused by ~opt: a non-blocking reviewer's findings are still real fixes, and merging a tree that isn't the reviewed tree is not what ~opt opts out of. Backstops in do:pr at the two moments unpushed work becomes visible damage: Open the PR pushes first if the branch is ahead, and the merge gate refuses to merge while it is, reporting the unpushed SHAs. Both checks compare against the upstream ref specifically (git status says nothing about committed-but-unpushed commits) and skip when there is no upstream, so a never-pushed branch under /do:review or /do:better is unaffected.
…licit refspec, keep hard-errors dirty - The inconclusive rule's trailing '~opt passes are ignored here' contradicted the push-failed carve-out three clauses earlier, so an ~opt reviewer's stranded fixes could still compute clean and merge. Scope the exemption to the statuses it covers. - Push with an explicit refspec: a bare 'git push' under push.default=matching fans out to every same-named local branch, including a release branch that auto-tags. - Skip the assertion when the pass committed nothing, so it never publishes deliberately-unpushed local work on a pass that had no fixes of its own. - A hard-error keeps its status instead of being rewritten to push-failed, which would downgrade the aggregate from dirty to inconclusive and slip past do:pr's abort-before-creating-the-PR gate. - Add the missing push-failed enumeration to do:release's restatement of the rule.
…cal branch name
`git push origin HEAD` resolves <dst> to `refs/heads/<local-branch-name>` and
ignores the upstream, so on a branch whose upstream is named differently it
creates a spurious remote branch, leaves the real PR head stale, and
`@{u}..HEAD` stays non-empty — issue #134's exact failure, reintroduced inside
the guard against it. Reproduced in a scratch repo before fixing. The push target
is now derived from the upstream ref the check already resolves.
Also from this round:
- Run the assertion on hard-error passes rather than asserting they always no-op.
A faithfully-executed loop reverts to its start commit and the check falls
through, but an improvised loop body may have skipped the revert — which is the
case this whole assertion exists for.
- Use `if` instead of a trailing `[ … ] && …`, so the snippet doesn't exit
non-zero on the live path and read as a failed command to the orchestrator.
- Make the test guards structural: a reworded but still-unqualified `~opt`
exemption would have slipped past the literal-string match.
… in the same shell
Two failure modes reproduced in a scratch repo before fixing:
- Splitting the abbrev-ref mangles two real upstream shapes. A branch tracking a
LOCAL branch (`branch.<n>.remote=.`, what `git branch --set-upstream-to=main`
produces) abbreviates to a bare `main`, so both halves of the split return
`main` and the push targets a nonexistent remote — and on such a branch
`@{u}..HEAD` is non-empty by construction, so the assertion fires on a healthy
branch and records a false `push-failed` that blocks the merge. A remote whose
name contains a slash (`up/stream/main`) mis-splits the same way. Derive remote
and branch from `branch.<n>.remote` / `.merge` instead, and treat a local
upstream as "no remote to assert against". Since `.merge` is already fully
qualified the destination is `HEAD:$PUSH_BRANCH`, not `HEAD:refs/heads/…`.
- The push was prescribed in prose OUTSIDE the fenced block that defined
PUSH_REMOTE/PUSH_BRANCH. Spec snippets run as separate Bash invocations where
shell variables do not persist, so it would have executed against the
empty-string remote and reported another false `push-failed`. Fold the push and
its retry into the same block; parallel step 6 now reuses that whole block
rather than restating a push whose variables nothing in that section defines.
Tests made structural: every prescribed push is scanned from raw text (the
in-block occurrences carry no backticks) and must use the derived form.
…n rule self-contained - do:pr's unpushed-commits gate printed the SHAs and left the decision to adjacent prose. Since the failure this whole change guards against IS a step being skimmed past, the snippet now exits non-zero instead of emitting output someone has to interpret. - The `clean` aggregate rule excluded push-failed only by rule ordering (the inconclusive rule matches first). State it explicitly so the rule is true on its own terms — the round-1 CRITICAL in this same file was exactly a later sentence quietly contradicting an earlier carve-out. - Relax three test regexes that pinned incidental formatting (an exact three-space comment gap, an adjacency assumption between a guard and its body, and a 600-char proximity window) so a reformat with no behavior change can't fail them. Rejected from this pass: the claim that `git rev-parse --abbrev-ref --symbolic-full-name` combines conflicting flags and errors. It is a standard idiom and ran correctly here on every upstream shape tested. Also rejected a suggestion to let the prescribed-push scan span newlines, which would make the regex greedy across the retry line and match less precisely than it does now.
…tranding the branch The retry's `git pull --rebase --autostash` can hit a content conflict, and nothing aborted the half-finished rebase. Reproduced end to end: after a concurrent push to the same branch plus an overlapping edit, the block left `rebase in progress`, HEAD detached, the tree conflicted, and the pass's own fix commit reachable only as REBASE_HEAD. That is worse than the state issue #134 describes, because `push-failed` is a continue-signal: the wrapper goes on to the next reviewer, which now runs against a detached, conflicted tree — and that reviewer's own push assertion silently no-ops, since a detached HEAD has no resolvable @{u}, so PUSH_REMOTE is empty and the check skips. The guard built to surface stranded fixes would report nothing. The inner loops carry the same pull-rebase-then-push idiom, but there it is the loop's last act before reporting failure; here execution deliberately continues, which is what turns a recoverable failure into a corrupted tree for every later pass. Abort the rebase on the conflict path and keep the non-zero exit that drives `push-failed`, so the branch is always returned exactly as the reviewer left it.
…e-branch The bare 'false' — the line that makes a failed push observable to the orchestrator — was guarded by nothing. Deleting it left the suite green while flipping the abort path's exit status to 0 (git rebase --abort succeeds), so a genuine push failure would read as success, the pass would never be recorded push-failed, and the stranded commits would reach the merge gate: issue #134 verbatim. The old assertion also matched 'git rebase --abort' anywhere in the file, so moving the abort out of the else-branch and merely naming it in prose kept the suite green with a conflicted rebase left in place. One anchored assertion catches both. Verified by re-running both mutations: each now fails exactly one test.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #134
Summary
Every reviewer loop pushes its own fix commits as its final step, but nothing downstream ever verified it happened. An orchestrator that improvised a loop body instead of following it left the fixes committed locally:
gh pr createopened a PR containing only the pre-review commit, every reviewer still reportedclean, CI still passed (it was testing the pushed tree, green because it predated the fixes), and the merge gate had nothing to object to. The failure was completely silent — the incident in #134 lost four review findings, including a restored data-loss guard, and neededgit reflogto recover them.Primary fix —
lib/multi-reviewer-loop.md. Series dispatch gains a step 5 that asserts the pass's commits reached the remote, pushes them with the loop files' own one-retry, and records a newpush-failedstatus if the push still fails. Parallel dispatch reuses the same block after its single union apply. Landing it in the wrapper fixes it once for all seven commands that drive it (pr,pr-better,review,release,better,better-swift,depfree).push-failedis inconclusive and never merge-eligible, and — unlike the other inconclusive statuses — is never excused by~opt. A non-blocking reviewer's findings are still real fixes;~optopts out of blocking on a missing verdict, not out of noticing that the merged tree isn't the reviewed tree. It is wired into the stop-mode table, the stop-mode prose, the aggregateinconclusiverule, thecleanrule, the optional-exclusion note, the parallel-mode aggregate, and/do:release's restatement of the same rule.Backstops —
commands/do/pr.md. At the two moments unpushed work becomes user-visible damage: Open the PR pushes anything outstanding first and refuses to create the PR if that push fails, and the merge gate refuses to merge while the branch is ahead of its remote, naming the unpushed SHAs. The merge-gate snippet exits non-zero rather than printing a result someone has to interpret — the failure mode here is precisely a step being skimmed past.Edge cases handled
/do:reviewand/do:bettercan run on a never-pushed branch; the check no-ops rather than erroring. Detached HEAD and a missing origin take the same path.branch.<n>.remote=.). On such a branch@{u}..HEADis non-empty by construction, so a naive check would fire on a perfectly healthy branch and record a falsepush-failedthat blocks the merge. Treated as "no remote to assert against".branch.<n>.remote/.merge, not by splitting the abbrev-ref — a remote name may itself contain a slash, and a local upstream abbreviates with no slash at all. Since.mergeis already fully qualified the destination isHEAD:$PUSH_BRANCH, neverHEAD:refs/heads/….git push(fans out underpush.default=matching) and nevergit push origin HEAD(resolves<dst>to the local branch name, so on a differently-named upstream it creates a spurious remote branch and leaves the PR head stale — Assert review-fix commits are pushed before opening a PR or merging #134's own failure).dirty. Acli-error/broken-build/test-failed/rejectedpass keeps its status instead of being rewritten topush-failed, which would downgrade the aggregate toinconclusiveand slip past/do:pr's abort-before-creating-the-PR gate.--reviewer-appliesis unaffected — that flag changes who edits, not who pushes.Test plan
npm test— 185 pass, 0 fail (3 new contract tests intest/review-loop-contract.test.js).push-failedreaching every aggregate rule that must consume it; the~optexemption being scoped rather than unconditional; the config-based target derivation and local-upstream skip; every prescribed push using the derived form (scanned from raw text, since the in-block occurrences carry no backticks); and bothdo:prguards.git pushfailure modes were reproduced in scratch repos before fixing, not reasoned about:git push origin HEADon a branch tracking a differently-named upstream created a spuriousrefs/heads/local-nameand left@{u}..HEADnon-empty; a local-tracking upstream abbreviated to a baremain, mangling the split into a bogus remote.refs/heads/next/issue-134).Review status
--review-with claude,ollama~opt(saved default), series mode.guardrailcleanAggregate:
inconclusive— the claude pass hit its built-in 3-iteration cap while still landing substantive findings, which isguardrailrather thancleanby the loop's own rules ("nobody chose that ceiling"). Left unmerged deliberately: a further round may still have something to say.Round 2 caught a CRITICAL that round 1's own fix introduced —
git push origin HEAD, which silently reintroduced #134's exact failure inside the guard against it. Round 1 caught a self-contradiction where theinconclusiverule's trailing "~optpasses are ignored here" negated thepush-failedcarve-out three clauses earlier, which would have let an~optreviewer's stranded fixes merge.