Skip to content

fix([issue-134]): assert review-fix commits are pushed before opening a PR or merging - #137

Merged
atomantic merged 9 commits into
mainfrom
next/issue-134
Aug 12, 2026
Merged

fix([issue-134]): assert review-fix commits are pushed before opening a PR or merging#137
atomantic merged 9 commits into
mainfrom
next/issue-134

Conversation

@atomantic

Copy link
Copy Markdown
Owner

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 create opened a PR containing 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 — the incident in #134 lost four review findings, including a restored data-loss guard, and needed git reflog to 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 new push-failed status 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-failed is 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; ~opt opts 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 aggregate inconclusive rule, the clean rule, 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

  • No upstream. /do:review and /do:better can run on a never-pushed branch; the check no-ops rather than erroring. Detached HEAD and a missing origin take the same path.
  • Upstream tracking a local branch (branch.<n>.remote=.). On such a branch @{u}..HEAD is non-empty by construction, so a naive check would fire on a perfectly healthy branch and record a false push-failed that blocks the merge. Treated as "no remote to assert against".
  • Push target derivation. Taken from 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 .merge is already fully qualified the destination is HEAD:$PUSH_BRANCH, never HEAD:refs/heads/….
  • Never a bare git push (fans out under push.default=matching) and never git 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).
  • Scoped to the pass. A pass that committed nothing skips the check, so it never publishes deliberately-unpushed local work.
  • Hard errors stay dirty. A cli-error/broken-build/test-failed/rejected pass keeps its status instead of being rewritten to push-failed, which would downgrade the aggregate to inconclusive and slip past /do:pr's abort-before-creating-the-PR gate.
  • --reviewer-applies is unaffected — that flag changes who edits, not who pushes.

Test plan

  • npm test — 185 pass, 0 fail (3 new contract tests in test/review-loop-contract.test.js).
  • New tests pin: the assertion and its no-upstream skip; push-failed reaching every aggregate rule that must consume it; the ~opt exemption 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 both do:pr guards.
  • Both git push failure modes were reproduced in scratch repos before fixing, not reasoned about: git push origin HEAD on a branch tracking a differently-named upstream created a spurious refs/heads/local-name and left @{u}..HEAD non-empty; a local-tracking upstream abbreviated to a bare main, mangling the split into a bogus remote.
  • The prescribed push block was dogfooded — the last three commits on this branch were pushed with exactly the command the spec now prescribes, including surviving a slash in the branch name (refs/heads/next/issue-134).

Review status

--review-with claude,ollama~opt (saved default), series mode.

Pass Agent Optional Iterations Status Notes
1 claude no 3/3 guardrail 13 findings across 3 rounds, all applied
2 ollama yes 1/3 clean qwen3.6:35b; 8 findings, 5 applied, 3 rejected

Aggregate: inconclusive — the claude pass hit its built-in 3-iteration cap while still landing substantive findings, which is guardrail rather than clean by 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 the inconclusive rule's trailing "~opt passes are ignored here" negated the push-failed carve-out three clauses earlier, which would have let an ~opt reviewer's stranded fixes merge.

… 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.
@atomantic atomantic changed the title assert review-fix commits are pushed before opening a PR or merging fix([issue-134]): assert review-fix commits are pushed before opening a PR or merging Aug 12, 2026
…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.
@atomantic
atomantic merged commit 6645cb8 into main Aug 12, 2026
4 checks passed
@atomantic
atomantic deleted the next/issue-134 branch August 12, 2026 22:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Assert review-fix commits are pushed before opening a PR or merging

1 participant