Use when opening, gating, reviewing or merging a pull request in this repository, running the pre-PR gate, writing the body, making a branch testable, handling Codex findings, or sequencing several branches at once.
Scanned 9/13/2026
Install to Claude Code
npx -y skills add ubermuda/loupe --skill working-with-prs --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Working With Prs?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/ubermuda-working-with-prs)More formats (shields.io, HTML) on the badges page.
---
name: working-with-prs
description: Use when opening, gating, reviewing or merging a pull request in this repository, running the pre-PR gate, writing the body, making a branch testable, handling Codex findings, or sequencing several branches at once.
---
# Working with pull requests
Every change reaches `main` through a pull request. `main` is protected, so no
path skips this.
## The gate, before you open anything
1. `just cs` applies the formatter and rector fixes. Commit anything it
changed. It works from a worktree: the finder uses explicit excludes and
throws if it matches zero files, so a vacuous pass is not possible.
2. `just ci` is check-only. It reports style and rector violations but never
rewrites files; `just cs` is the step that applies them. Fix every failure,
including ones that pre-date your change.
3. Run a Codex review with `mcp__codex-cli__review` and `model: "gpt-5.6-sol"`.
Always pass the model explicitly. This Codex account rejects the model the
tool picks by default.
Review against `origin/main`, never `main`. A worktree's local `main` is often
stale, so a review against it reports findings for already-merged code.
**One clean pass is not a pass. Run until two consecutive passes come back
clean.** The same review on the same commit gives different answers each time.
Two runs on one branch, same model and same base, disagreed: the second found a
real defect that sat in the tree the first had called clean. The habit that
protects you is the one an agent falls into anyway when it fixes findings and
re-runs. Branches that stopped at their first clean answer are the ones with
the least evidence behind them, however green they look.
**Scope the review to the commit, not the base, once a branch has more than
one commit.** `mcp__codex-cli__review` takes `commit: "<sha>"` for this. A
`base` review on a long branch can come back clean while describing only the
branch's oldest work. One branch took three clean runs: the two scoped to
`origin/main` summarised its original creation path and never named the update
path its newest commit changed, and only the commit-scoped run described the
code under review. The two agreeing runs proved nothing, because both drifted
the same way. The risk is highest when the newer commits are a different
kind of work from the branch's original purpose, because a summary of the
branch's theme then covers none of them. Commit count is the trigger because
it is mechanical, and the cost of scoping when you did not need to is one
run. A single-commit branch is not exposed to this.
**Read the summary, not only the verdict. A clean result that never mentions
the largest thing in the diff is a pass that missed the diff.** One branch took
two clean passes whose summaries described the search plumbing and named
neither the form, the wizard, the picker nor the 31 new translations that made
up most of the change. Both were clean because neither looked. A second run
does not help here, because that pass is stable and wrong the same way every
time. Name the biggest thing you changed, then check the summary mentions it.
Both rules cost time on a large diff. Say in the PR body how many passes ran
and what the last one covered, so a reader can weigh the evidence rather than
read "Codex: clean" and assume it means more than it does.
e2e is not in the local gate. The `e2e` required check on the PR gates the
suite, and it runs the same `just e2e` on a disposable runner. Push, then read
that check. Fix every failure it reports, including pre-existing ones. Do not
run the full suite locally before you open the PR. See "Running the suite
locally is debugging, not gating" for the cases that still want a local run.
If `mcp__codex-cli__review` is not available, STOP and tell the owner. A missing
MCP server is a configuration fault worth investigating, so do not route around
it. Known cause: `codex-cli` is registered per-project in the user's own
configuration rather than in a committed `.mcp.json`, so a session running from
a worktree path may not pick it up.
A `codex review` with no output for ~5 minutes at near-zero CPU is hung,
typically at MCP startup. Kill it and fall back to:
```bash
codex exec -c model="gpt-5.6-sol" "Review the diff of this branch against origin/main (git diff origin/main...HEAD) for correctness bugs and convention violations. Actionable findings only."
```
## A check is a fault until you have seen it fail
A check that comes back clean is a fault in the check, until it has failed on a
known-bad input.
A check that comes back one short is a fault in the check, until you prove
otherwise.
Nobody tests the second one, because a check that reports a problem looks
self-justifying.
A tool reports what it did rather than what it found. Doing nothing successfully
and finding nothing wrong produce the same output. So break the check on
purpose, watch it go red, then restore it and trust the green.
Confirm the break landed before you read the result. One session broke a rule
with a `perl` one-liner whose escaping was wrong. The file did not change, the
suite stayed green, and that green read as "the rule is fine". `git diff
--quiet` caught it. A falsifier that silently does nothing is the same fault one
level up, and it is the more convincing of the two.
| Signal | What it cannot distinguish |
|---|---|
| `phpstan` exit 0 | a clean tree from a rule that never loaded |
| a test that pins behaviour | the behaviour existing from the test not asserting it |
| `just cs` reporting success | a fixer that ran from a rule that silently no-opped |
| a conflict report naming one file | the other files being unchanged from being reverted |
| `pass=10` on a pull request | a current head from a head that has since moved |
Four sessions hit this about fifteen times in one evening on 2026-09-11. The
falsifier is what broke it each time: a file calling a constructor with no
arguments, so the arity error proves the rule runs; the forbidden filter added
to the listener, so the test that forbids it goes red.
Grep is where the second direction bites. A grep for the five `var/tailwind`
negation rules in `.gitignore` matched four, because the pattern could not match
a bare `!/var/`. All five were present. Two sessions made that mistake on the
same five lines, with different patterns, forty minutes apart. Read the region
rather than grepping for what you expect to find.
### Prove a resolution lost nothing
A union resolve on a shared prose file can keep your line and drop someone
else's. Nothing in this repository reports that. To answer it for any file where
branches append at a shared anchor:
```bash
git show origin/main:<file> | grep "^<entry prefix>" | sort > a
grep "^<entry prefix>" <resolved> | sort > b
comm -23 a b # on main, missing from the resolution: must be empty
comm -13 a b # invented by the resolution: this branch's own lines alone
```
Counting occurrences per entry is weaker. It needs a hypothesis about what to
expect, so it catches only a loss somebody thought to look for. `comm` needs no
hypothesis and answers both directions at once.
### Ask what reads what you write
Inert code looks wired up when its input is present. A changelog fold proposed
for `docker/prod/release.sh` reads as plausible, because `COPY . .` genuinely
puts `docs/CHANGELOG.md` into the production image. Nothing reads it there:
`grep -rn CHANGELOG src/ templates/ config/ public/` returns nothing, and the
container filesystem is discarded on each deploy. The fold would have run,
reported success and changed nothing.
For any step that writes something, name what reads it, and require a grep that
finds the reader. An input that exists is not evidence that anything consumes
the output.
## Documentation-only branches run steps 1 and 2 only
Skip the Codex review. Say so in the PR body, so the record shows the gate was
reduced deliberately rather than forgotten. CI still runs its own checks
including `e2e`; you skip the review, not them.
The test is the diff, not the intent. Every changed file must end in `.md`.
Check it, do not assume:
```bash
git diff --name-only origin/main...HEAD | grep -v '\.md$'
```
Any output at all means the full gate applies. A branch that also touches
`.env`, a Twig template, a fixture, `composer.json` or a `justfile` recipe is
not documentation-only, however small the change looks.
`just ci` still runs, because Markdown is not inert here: prettier covers some
of it, gamache's checks read `docs/`, and a docs commit can break a build that
greps them.
The reduced gate does not extend to Markdown that a script *executes*. A `.md`
file a script parses for commands is code wearing a `.md` suffix, so gate it
fully. A `SKILL.md` sits at the edge: an agent reads it rather than a script
parsing it, so the reduced gate applies. Say in the body that you made that
call.
The reason is proportion. Asking a reviewer model to read prose for correctness
bugs is cost with no signal, and running a gate that can never fail teaches a
reader to stop trusting gate results.
## Open it ready, not draft
The owner reviews ready pull requests only. A draft is invisible to him, so a
finished branch left in draft waits for a review that never starts.
Mark a pull request ready as soon as its gate is green and the Codex review is
clean. Use `gh pr ready <number>` if you opened it as a draft. Do not wait for
the owner's review to un-draft it, because that is the wrong way round.
Open a draft only while the branch is unfinished, and say in the body what is
still missing. Ready does not mean merged: `main` still needs one approving
review, and you never approve your own work.
## Write the body for two readers
`main` allows squash merges only, so the PR body becomes the commit body. It is
permanent history.
- Say what changed, and why you rejected the alternative.
- Record what you verified and how. "Reverted the fix and watched the test
fail" is worth more than "added tests".
- Name what you could not verify, in the body rather than buried in a comment.
"Terraform is validate-only, the cloud path is unverified" is trustworthy in a
way that silence is not.
- Say which gate you reduced or skipped, and why.
- Put any deploy-time need in the body: a rerender, a cache clear, a stack
recreation. The person merging is not necessarily you.
## Make the branch testable, not just reviewable
A reviewer who has to build state by hand usually will not.
**The preview links go at the very top of the body, under a `## Preview`
heading, before the decision, the summary and everything else.** They are the
first thing the owner looks for, so nothing goes above them. A demo section
called "Try it", "Click it" or "Verification", sitting two thirds of the way
down next to the gate results, makes the reviewer hunt for the one thing they
opened the page to find. Lead with the links, then explain the change.
Give each link a one-line label saying what state it shows. When a change has
several states, seed one document per state and link each: a reviewer who can
see all of them side by side reviews what the code does, rather than the one
case you happened to seed.
Point at the running instance, and sign the reader in. Every worktree serves its
own branch at `https://<slug>.loupe.dev.localhost`. Do not put a bare page URL
in the body, because it lands the reader on the login page and they must sign in
by hand. Mint a signed link instead, from inside the worktree:
```bash
( cd .claude/worktrees/<name> && bin/worktrees/compose-exec.sh \
bin/console app:dev:preview-login-link --path=/projects )
```
The link signs the reader in and lands them on the page you named, so the body
needs no credentials at all. Use `--email=admin@loupe.test` for an admin page.
It does not expire, so it still works when the reviewer reads the body days
later. The signature covers the whole URL, host included, so a link works
against that worktree only, and the host resolves on your own machine. The route
is `#[When('dev')]`, so it does not exist in production.
Open the link yourself before you write it down. Say plainly when a branch has
no worktree or nothing to click, rather than pasting a link that goes nowhere.
**Every such URL must be clickable, and that means one whole absolute URL in
plain text.** A bare host followed by paths in backticks — the shape a body
falls into naturally when there are several pages to point at — renders as
unclickable code spans, and the reviewer has to assemble each URL by hand. That
is enough friction to lose the click the seeding was for. So: no backticks
around a URL, no relative paths under a host given once, no `<slug>` or other
placeholder left for the reader to substitute. Write the full
`https://<slug>.loupe.dev.localhost/projects/…/review` per destination, even
when that repeats the host five times, and paste one into a browser before
opening the PR.
Strip the `## Preview` section out of the body before you merge. `main` squashes,
so the body becomes the permanent commit message, and a signed
`*.dev.localhost` link points at a worktree that is torn down within hours. Name
the commit the preview was built from, so a later reader knows which code the
links showed. Never write "this branch's head": the phrase is true of every
branch at every moment, so no reader can check it.
The same rule covers a branch with no worktree of its own: link the page on
whatever instance does serve it, rather than describing the route and leaving
the reader to construct it.
"Nothing to click" is a claim about the reviewer's options, not about routing.
A branch that adds no route can still produce reviewable output — HTML from a
renderer, a generated report, a file — and saying it has nothing to look at
because nothing is wired up is wrong, and reads as though the work cannot be
judged until a later branch lands. Publish the output as an artifact and link
it, and be clear about which questions it answers and which it defers.
Seed the data the change needs. `bin/console app:dev:seed` creates a user, an
admin and a project, and no documents, comments, verdicts or exports. A diff
feature is untestable without a document carrying several versions. Create
fixtures that contain the exact shape the bug had, not merely a happy path, and
tell the reviewer which one to open and what to look for.
Seed with a temporary `#[When('dev')]` console command created inside the
worktree. Run it, delete it, then confirm `git status --porcelain` is empty.
Fixtures must never reach the branch.
## Reviewing work you did not write
A green gate is not evidence the change is correct. In one wave of six branches,
all green on `just cs`, `just ci` and e2e, Codex found a real defect in every
one, and two of them could lose data. The gate proves the suite passes. It does
not prove the change is right.
So read the diff yourself, especially the part the author called routine. Where
an agent reports a decision it made on your behalf, check whether it is really
orthogonal to the open questions it claims not to touch. One wave branch added a
unique index and called it unrelated to a design decision it foreclosed.
Send findings back to whoever wrote the code, not to a fresh agent. The author
still has the context, and the second-order questions ("does anything else in
this file have the same shape?") catch the bug the reviewer did not spot. Ask
for the failure to be reproduced against the pre-fix code, so you know the new
test discriminates rather than passing vacuously.
## Running the suite locally is debugging, not gating
CI's `e2e` check is the gate. It runs the same `just e2e` against a disposable
stack on an isolated runner, with both `E2E_BASE_URL` and `MAILPIT_URL` set
correctly. See `.github/workflows/ci.yml`.
Locally the same suite is slower, destructive, and measurably less truthful.
Across one wave of five branches, every local e2e problem was environmental and
CI passed all five: three runs lost to the `MAILPIT_URL` omission below, one to
cross-worktree contention that CI then cleared. A gate that fails for reasons
unrelated to the diff is worse than no local gate, because someone has to spend
judgement deciding which failures to believe.
So push, then read the check. Fix every failure it reports, pre-existing ones
included.
Run it locally when you are working on a spec, not when you are finishing a
branch. These cases earn it:
1. A named spec you are changing or debugging: `just e2e
tests/<area>/<spec>.spec.ts`. It is fast, and the only way to iterate.
2. A branch you cannot push yet, or one whose CI run you need to pre-empt for a
reason you can state.
Neither case is the full suite before opening a PR.
### Aiming a local run at a sibling worktree
This is still the right tool for the two cases above when the branch lives in
another tree. Set both variables:
```bash
E2E_BASE_URL=https://<slug>.loupe.dev.localhost \
MAILPIT_URL=https://mailpit-<slug>.loupe.dev.localhost \
just e2e --workers=1
```
`E2E_BASE_URL` alone suppresses worktree detection, so the run reads the
*shared* Mailpit while the worktree's app sends to its own sidecar. Nothing
collides and nothing warns, and the assertions never see a message. About two
dozen registration, login and verification specs then fail in a way that reads
as broken auth rather than broken mail: each registration *succeeds*, times out
waiting for mail that went elsewhere, and its retry fails as a duplicate email.
Diagnose it by running `bin/e2e-target.sh` with and without the variable and
diffing the fourth line, not by re-running.
Warm its cache first:
```bash
( cd .claude/worktrees/<name> && bin/worktrees/compose-exec.sh bin/console cache:warmup )
```
This destroys that worktree's dev data. The suite's `install-reset` project
truncates every table. `just e2e` normally repairs the worktree afterwards, but
`bin/e2e-target.sh` deliberately blanks the worktree name when you set
`E2E_BASE_URL` by hand, so the automatic repair does **not** run for exactly
this invocation. Repair it yourself:
```bash
bin/worktrees/worktree-bootstrap.sh <worktree-name> # from the main checkout
```
Use bootstrap rather than a bare `app:dev:seed`. Bootstrap re-seeds and also
re-runs the migrations, restores the per-worktree `.env.local` values and
rebuilds the stylesheet.
Two runs that omit `MAILPIT_URL` cannot overlap. Each worktree has its own
Mailpit sidecar, so runs started with plain `just e2e` in different worktrees
are isolated, and so are runs that set both variables above. Two runs that set
only `E2E_BASE_URL` both fall back to the *shared* instance and read each
other's mail. Serialise those, or give each its own `MAILPIT_URL`.
## Merging
1. A branch's final gate happens on the branch **fully merged with current
main**. A branch cut before a sibling merged has not really been gated: its
e2e run never exercised the sibling's specs against its code.
2. Merge **immediately** after the gate goes green. Every merge to `main`
between a branch's last sync and its own merge invalidates its PR through
conflicts, or invalidates its gate.
3. After **every** merge, run `just cs` on main and commit the drift. Merge
unions of two individually-clean branches produce fixer drift that otherwise
lands on whichever branch syncs next. The changelog needs nothing from you:
the documentation deploy folds the fragments itself on every push to `main`.
4. Tear down a merged branch's worktree only **after** `gh pr merge` is
confirmed, never in the same command chain. Confirm it by reading the result,
not by having issued the command. A merge can fail for a reason that is not a
conflict (mergeability still computing, a check that flipped), and destroying
the tree first turns a retry into a rebuild from origin. Nothing is lost
while the branch is still on origin, so verify that before anything else.
5. A conflict-free merge is not a correct merge. When one branch renames a
class, a *new* file on another branch merges cleanly while still importing
the old name. Git sees no conflict, because the file never existed on both
sides. Run `just ci` before trusting such a merge; phpstan catches this, git
does not.
The signature variant is the same blind spot with a different tell. A branch
that makes an argument required lives on and absorbs merges, and each
incoming merge can bring a *new* file from a sibling that constructs the old
shape. It existed on only one side, so git has nothing to report, and the
result is a runtime `ArgumentCountError`. Grepping for the changed symbol is
a good first pass and not sufficient: it finds only the shapes you thought to
search for, and goes stale at the next merge.
A formatter that reads a signature belongs to the same family. `just cs`
moved a `number:` argument to the last position, on a branch whose `Card`
class had no `$number` property because the parent branch added it. The rule
is Rector's built-in `SortCallLikeNamedArgsRector`, reached through
`GamacheSetList::CONVENTIONS` and declared in
`vendor/ubermuda/gamache/src/Rector/config/conventions.php`, so a grep of
`rector.php` finds nothing. It sorts by `$order[$name] ?? \PHP_INT_MAX`, so a
name the constructor does not carry sorts last.
An unchanged file does not prove the ordering is correct. The rule bails out
when it cannot resolve the class, and a silent no-op reads exactly like a
pass. A green `cs-check` dry-run does not prove it either: one branch's
dry-run flagged nothing, and `just cs` then reordered that file with the same
rule set in both legs.
Verify on a detached HEAD that merges the branch onto its parent. Run the
tests there, then return with the branch ref unmoved. That tests the change
in its destination rather than in the tree it was written in. Read the
parent's version of one file with `git show origin/<parent>:<path>`, which
has no effect on the cut point.
6. Resolving a conflict by taking one side can silently revert the other side's
fix. Before accepting a resolution, re-verify the *behaviour* both branches
were protecting, not just that the markers are gone. The sharpest form is
rename/rename, where neither side is correct and the answer is the newer
branch's **content** at the other branch's **path**.
7. "Pull Request is not mergeable" right after `git push` usually means GitHub
has not recomputed mergeability yet. Wait a few seconds and retry.
8. Read the checks against the head you are about to merge. After you sync a
branch, the rollup can still describe the *previous* head, so a green reading
is evidence about a commit that no longer matters. Key the wait on
`headRefOid`, then count the checks:
```bash
gh pr view <n> --json headRefOid -q .headRefOid
gh pr checks <n> --required --json bucket \
-q 'group_by(.bucket)|map("\(.[0].bucket)=\(length)")|join(" ")'
```
Count `pass`, `fail` and `pending` against the number of checks the ruleset
requires. A green reading is `pass=<that number>` and nothing else. Observe
all-green, and never infer it from an absence. The rule covers the polling
monitor and the one-shot check before a merge. A monitor that reports early
costs you a wait. A pre-merge check that reports a false green is acted on at
once, and the merge happens.
Lead with `gh pr checks`. Its `--required` flag reads the ruleset, so your
denominator cannot go stale the way a written list does. It prints an
explicit `pending` bucket, so it routes around the field problem below rather
than navigating it. Its `--json` mode exits 0 while checks are pending, so
read the counts rather than the exit code.
`gh pr view --json statusCheckRollup` gives you the raw rollup, and it needs
care. A GitHub Actions entry is a `CheckRun`, whose progress field is
`status`, with `QUEUED`, `IN_PROGRESS` and `COMPLETED`. Its `state` is always
null, because `state` belongs to `StatusContext`. Its `conclusion` is an
empty string until the run finishes, and jq's `//` falls through on `null`
and `false` only. Read `conclusion`, then `status`, then `state`:
```bash
gh pr view <n> --json headRefOid,statusCheckRollup -q '
def st: if (.conclusion // "") != "" then .conclusion
elif (.status // "") != "" then .status
elif (.state // "") != "" then .state
else "PENDING" end;
"\(.headRefOid) \([.statusCheckRollup[]|st]|join(","))"'
```
The two expressions on the same ten checks, five of them unfinished:
```
NEW: SUCCESS,IN_PROGRESS,IN_PROGRESS,SUCCESS,SUCCESS,IN_PROGRESS,IN_PROGRESS,SUCCESS,SUCCESS,IN_PROGRESS
OLD: SUCCESS,,,SUCCESS,SUCCESS,,,SUCCESS,SUCCESS,
```
Test any replacement against a pull request that has an unfinished check.
Two expressions agree once everything has completed, so a settled pull
request proves nothing. Build a fixture by copying a row from a real `gh`
call, with its `status` and its null `state`. A hand-composed row that looks
right is not evidence.
A rollup with fewer entries than the ruleset requires is the same hole one
level up: a run that has not registered reads identically to "nothing left to
wait for". Counting closes both.
## The changelog entry rides the pull request it describes
Every pull request that changes something a reader can act on carries its own
changelog fragment, in its own branch, at `changelog.d/<number>.md`. Write it as
you write the change. Do not open a second pull request for it. Never edit
`docs/CHANGELOG.md` itself.
Name the file after the pull request number, and anchor the entry to the same
number. `changelog.d/209.md` reads:
```
- (#209) — **Fixed:** what changed, from the reader's side.
```
The number exists as soon as you open the pull request. The squash SHA does not,
which is why the old rule sent the entry to a later branch. The squash commit's
subject ends with `(#209)`, so `git log --first-parent --grep='(#209)$'` recovers
the commit from the number, and `gh pr view 209 --json mergeCommit` gives the
SHA directly. Entries written before this rule carry a SHA as well. Leave them
alone.
Keep the `$` on that grep. `--grep` reads the whole commit message, and a body
that cites another pull request in the same `(#209)` form matches as well. The
log already holds 50 such citations, and `--grep='(#371)'` returns five commits
with the real one last. The anchor ties the match to the end of the subject
line, where the squash number lives, and returns exactly one.
Re-read your own entry when review changes what the branch does. The entry now
rides the branch, so a feature cut in review leaves a line describing work that
never shipped. It is your own diff, which is the one you stop reading.
One fragment per pull request means two branches never write one file, so two
entries never conflict. That replaces the old rule, which put every entry at the
top of `[Unreleased]` and made the conflict certain. On 2026-09-11 three merges
each turned every remaining branch CONFLICTING, and `docs/CHANGELOG.md` was
almost always the only conflicted file.
The fragments reach a reader twice, and neither step is yours. The
documentation deploy folds them on every push to `main`:
`.github/workflows/docs.yml` runs `php bin/changelog.php --keep` before Astro
reads `docs/`, so the published changelog carries every merged fragment and the
files stay where they are. `just changelog` folds them into the committed file
and deletes them, which is the release step.
`php bin/changelog.php --check` reads the fragments and reports a malformed
one, and `just lint` runs it, so the gate catches a bad anchor on the branch
that wrote it. The check reads format only. Nothing asks whether a branch
carries a fragment at all, and that rule belongs in a pull request on
https://github.com/ubermuda/gamache.
An older entry keeps the position its SHA gives it in `git log --first-parent`.
Tag it with one of the six tags Keep a Changelog defines: `Added`, `Changed`,
`Deprecated`, `Removed`, `Fixed` or `Security`. `bin/changelog.php` rejects any
other word. Write one sentence saying what changed from the reader's side, and
leave the reasoning to the pull request body.
One entry per pull request, not one per branch. A branch that shipped six
features earns six lines, because a reader looking for when tags arrived should
find a line about tags rather than a paragraph about the wave that contained
them.
A pull request whose whole diff is `docs/CHANGELOG.md` and `changelog.d/` earns
no fragment. A fold is the usual case. The exemption keys on content, not on
subject: a pull request about changelog discipline that changes a skill file
does earn one, and it carries that fragment itself.
Reading an older entry means matching a 7-character SHA. Derive it with
`git rev-parse --short=7`, never with `git log --format=%h`. `%h` honours
`core.abbrev`, which is unset here, so git picks a length from the object count
and returns 8 today. A grep built on `%h` reports zero hits on every commit,
which reads as a file that records nothing. A row of zeros is a fault in the
check before it is a hole in the file.
## What the ruleset actually requires
`main` allows `--squash` only. A plain `gh pr merge` fails with
`GraphQL: Merge commits are not allowed on this repository`. Use
`gh pr merge <n> --squash`.
Do not diagnose a rejected merge with `gh repo view`. It reports repository
*settings*, which happily say all three merge methods are allowed while the
ruleset forbids two. The ruleset is the authority:
```bash
id=$(gh api repos/ubermuda/loupe/rulesets -q '.[0].id')
gh api repos/ubermuda/loupe/rulesets/$id -q '.rules[]|select(.type=="pull_request")|.parameters'
```
That prints **one approving review**. It also sets `require_code_owner_review`,
which is inert while the repository has no `CODEOWNERS` file. Read the required
checks from the same ruleset, named `main`:
```bash
gh api repos/ubermuda/loupe/rulesets/$id \
-q '.rules[]|select(.type=="required_status_checks")|.parameters.required_status_checks[].context'
```
On 2026-09-06 it printed ten contexts: `lint`, `cs-check`, `phpstan`,
`arkitect`, `gamache`, `audit`, `phpunit`, `e2e`, `js-test` and `cli-test`. Run
the command rather than trust that snapshot. It read eight until `js-test` and
`cli-test` arrived, and nothing in the repository fails when it goes stale.
An approval in chat is not a GitHub approval. Check before concluding a merge is
blocked by something else:
```bash
gh pr view <n> --json reviewDecision,mergeStateStatus
```
A job that runs in CI but is not in that list cannot block anything. Adding one
is a repository setting, so a branch cannot do it. If you add a CI job, say in
the PR body that requiring it is still owed, or the job is decoration.
**Never approve your own work.** The review itself stays with a human.
**Merging does not.** A PR that is *approved* with *all required checks green*
is good to merge, so merge it without asking. The approval already carried the
decision, and waiting for a second confirmation parks finished work in a queue. Follow the merge protocol above when you do:
sync with current main, let the checks re-run on the synced head, merge, then
run `just cs` on main.
So autonomous work means: open the PR, then merge it once the owner has approved
it and CI is green. Never merge something unapproved. Never merge past a failing
or pending check. Never use `--admin` to bypass either.
## Keep working, and stack when you are waiting
**Do not stop because a pull request is unmerged. Stack on it and carry on.**
Stacking is the owner's recommendation, and waiting for an approval is not being
blocked. Branch off the pull request your work needs, build the next piece, and
say in the body which branch it stands on and why.
Genuinely blocked means there is nothing you can do: a decision only the owner
can make, a credential you do not have, a service that is down. An unapproved
pull request is none of those. Neither is a queue.
This overrides the caution a session may carry from a bad wave. One wave of five
stacked board branches went wrong and was collapsed into a single pull request,
and that history is worth knowing, because it says what to watch rather than
what to avoid. Every failure in it was a merge that git could not see:
1. A formatter reversed a fix because the branch could not see its parent's
signature.
2. A required constructor parameter broke four files with no conflict reported.
3. A `CONFLICTING` pull request never ran CI at all, because `pull_request`
does not fire without a merge commit.
So the discipline is merge-protocol item 5, not abstinence. Run `just ci` on the
merged result rather than trusting a clean `git merge`, and check that the stack
is not `CONFLICTING` before you believe a green check.
Say in the body which pull request the branch stacks on. The reviewer needs it,
and so does whoever holds the merge queue, because a stack has an order that
approvals arriving out of sequence will not respect.
## Running several branches at once
Give each branch its own worktree and keep them off each other's files. Two
shared files used to collide on every branch, and neither does now. Open work
lives on the board rather than in the branch, and a changelog entry lives in
`changelog.d/<number>.md` rather than at one append point in
`docs/CHANGELOG.md`.
Sequence by blast radius. A branch that changes shared infrastructure, such as
compose files, environment resolution or CI wiring, should merge last, because
merging it invalidates the environment its siblings are still being gated in.
Say so in its body.
Land signature changes early. A branch that makes an argument required grows
more dangerous with every sibling merged ahead of it, because each merge can
bring a new file constructing the old shape (see merge protocol item 5).
Batch by file overlap, not by theme. Two agents editing the same module conflict
no matter how unrelated their tasks sound.
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!